diff --git a/docs/architecture/opencode-plugin-surface-audit.md b/docs/architecture/opencode-plugin-surface-audit.md index 760574dac9..f026d9e2b1 100644 --- a/docs/architecture/opencode-plugin-surface-audit.md +++ b/docs/architecture/opencode-plugin-surface-audit.md @@ -21,9 +21,10 @@ | OpenCode 能力 | P0 处理 | BitFun 承接位置 | 处理方式 | |---|---|---|---| -| `opencode.json` plugin 配置 | 可作为兼容输入进入 P0 | OpenCode 适配层 -> BitFun 插件来源只读视图 | 只读扫描,生成 provenance、manifest、hash、诊断和候选来源 | -| `.opencode/plugins/*.js|ts` | 可作为兼容输入进入 P0 | OpenCode 适配层 | 只识别文件形态和能力声明,不直接执行 | -| 全局插件目录 | 可作为兼容输入进入 P0 | OpenCode 适配层 | 只读导入;不继承 OpenCode 启用顺序 | +| 受管包内的 `opencode.json` | P0 只读解释 | BitFun 受管包 -> OpenCode 适配层 | 只读取清单声明并校验的内容;不安装或执行 npm 插件 | +| 受管包内的 `.opencode/plugins/*.js|ts` | P0 只读解释 | BitFun 受管包 -> OpenCode 适配层 | 只识别能力声明,不直接执行 | +| 用户已有 `opencode.json` 或项目 `.opencode` 目录 | 当前未实现 | 未来独立导入流程 | 转换为受管包后再进入适配层,不直接扫描 | +| 全局插件目录 | 后续可选导入来源 | 独立导入流程 | 转换为受管包;不继承 OpenCode 启用顺序 | | npm 插件列表 | P0 可诊断,执行属于后续 | OpenCode 适配层 | 只产出来源和 unsupported / projection-only 诊断 | | custom tool | 是,最小候选能力 | 扩展贡献接口 -> 工具 ABI | 映射为提供方候选(`ProviderCandidate`);进入最终工具链路前必须走权限和工具快照 | | permission hook | 是,候选能力 | 权限/副作用子接口 | 只能产生候选确认或诊断;不能直接批准 | @@ -57,24 +58,25 @@ | `runtime-ports` plugin contract | 已有主机 ABI、只读视图、候选项、权限提示、诊断和隔离类型;公开符号较多但已受脚本预算约束 | 不继续新增泛描述符;公开符号必须声明接口切面、消费方和验证目标 | | `plugin-runtime-host` | 已有受控 host 边界、deadline、幂等、隔离和 restart 清理路径 | 继续保持窄方法集;P0-C.1 来源接口不得直接泄漏主机 ABI | | `product-domains/plugin_source` | 定义生态无关的版本 1 包清单、来源标识、工作区信任记录和 epoch 规则 | `adapter` 仅为不透明标识;不得加入生态入口规则、文件系统、安装或主机行为 | -| `services-integrations/plugin_source` | 校验用户级和项目级 BitFun 目录中的全部声明文件,安全替换工作区信任记录 | 不解释 `.opencode` 布局,不扫描外部生态目录,不执行插件,不增加通用 registry/manager 接口 | +| `services-integrations/plugin_source` | 校验用户级和项目级 BitFun 目录中的全部声明文件,安全替换工作区信任记录,并为选定包生成固定内容输入 | 不解释 `.opencode` 布局,不扫描外部生态目录,不执行插件,不增加通用 registry/manager 接口 | | `bitfun-core/plugin_source` | 注入产品目录并向 CLI 保留来源与诊断兼容接口 | 不实现文件扫描、锁、持久化或生态解析 | | `bitfun-cli plugins` | 当前来源审核接口的产品消费方,支持 `list/approve-source/deny/revoke`;`doctor` 汇总严重来源错误 | `SourceApproved` 不得宣称能力已批准、包已启用或可执行,不承担安装复制和卸载 | -| `opencode-adapter` | 提供来源发现、诊断只读视图和受信任 custom tool 候选映射;未建立信任或暂不支持的能力返回诊断或 `unsupported` 状态 | 当前只验证适配器到 Plugin Runtime Host 的候选链路;生产组装接入须独立评审 | +| `opencode-adapter` | 消费固定内容的受管包输入,提供诊断只读视图和 custom tool 映射;未激活或暂不支持的能力返回诊断或 `unsupported` 状态 | 不拥有目录发现;生产组装和激活接入须独立评审 | | `events` | 已有产品事件清单 | 需要在真实插件事件消费前定义可订阅子集,不新增插件专用事件模型 | | `tool-contracts` | 已有动态工具提供方和工具快照 | custom tool 映射必须复用它,不新增插件专用工具 ABI | `opencode-adapter` 当前规则: -- 通过 `load_opencode_workspace_adapter` 接入 Plugin Runtime Host,并接收产品来源/策略侧生成的 `PluginSourceRef` 来源快照和信任 epoch。 -- 信任快照的 epoch 必须与本次 read/dispatch epoch 一致,否则只返回诊断,不产生受信任候选。 +- 唯一产品组装根调用公开工厂并把返回的适配器注入 Plugin Runtime Host;工厂只接收来源服务生成的受管包输入。 +- `SourceApproved` 仅表示包内容已经用户审核,适配器必须保持未激活状态,不得生成受信任候选。 - GUI、TUI/CLI、Web 等产品入口只消费能力服务接口、插件只读视图、诊断和稳定状态词。 - 适配器不执行 JS/TS、不安装 npm、不依赖用户本机 `opencode`。 +- 当前源码探测只识别测试覆盖的 `export const` 和同一行 `name: tool({` 声明形式,不提供完整 JS/TS 语法兼容;没有可识别入口的包和已识别但不支持的 hook 必须返回诊断,其他语法不属于本阶段兼容范围。 当前受管包规则: - 包清单文件为 `bitfun.plugin.json`;版本 1 的 `adapter` 是小写不透明标识。只有清单声明并通过哈希校验的文件进入来源标识和后续适配器访问范围。 -- `.opencode/plugins/*.js|ts` 只在 OpenCode 适配层中解释,不是产品域或来源模块规则,也不是对用户 OpenCode 配置目录的直接扫描。 +- `.opencode/plugins/*.js|ts` 只在 OpenCode 适配层中解释;文件必须先进入受管包清单并通过来源服务校验,不得由公开适配入口直接扫描用户 OpenCode 配置目录。 - 包内容变化后旧来源审核失效,新来源标识回到 `Unknown`;损坏的信任文件按失败处理且不自动覆盖。 - P0-C.2 不得把 `SourceApproved` 直接映射为 Host 的 `Trusted`;首次激活需展示适配器、入口、能力和副作用并重新确认。 - P0-C.1 没有生产 Host 绑定和 JS/TS 执行能力。 diff --git a/docs/architecture/plugin-runtime-host-design.md b/docs/architecture/plugin-runtime-host-design.md index 618764025e..5b7092894c 100644 --- a/docs/architecture/plugin-runtime-host-design.md +++ b/docs/architecture/plugin-runtime-host-design.md @@ -79,13 +79,13 @@ P0-C.1 只证明 BitFun 可以识别、校验和审核受管包;P0-C.2 才覆 ## 4. OpenCode 适配边界 -OpenCode 适配层是主机内部的兼容适配层。它读取 OpenCode 配置和插件文件,输出 BitFun 主机接口对象或诊断。 +OpenCode 适配层是主机内部的兼容适配层。它只解释来源服务提供的固定受管包内容,输出 BitFun 主机接口对象或诊断。 | OpenCode 输入 | BitFun 输出 | 当前边界 | |---|---|---| -| `opencode.json` plugin 配置 | 导入 provenance、manifest、hash、诊断、候选 BitFun 来源 | 可选导入,不执行 | -| `.opencode/plugins/*.js|ts` | 候选来源、配置诊断、能力诊断 | 不直接加载为权威状态 | -| 全局插件目录 | 候选来源和冲突诊断 | 不继承 OpenCode 启用顺序 | +| 受管包内的 `opencode.json` | 配置诊断和 npm 插件只读状态 | 不安装或执行 npm 插件 | +| 受管包内的 `.opencode/plugins/*.js|ts` | 候选来源、配置诊断、能力诊断 | 不直接加载为权威状态 | +| 用户已有项目或全局 OpenCode 目录 | 当前无输出 | 未来必须经独立导入流程转换为受管包 | | custom tool | `PluginEffectCandidatePayload::ProviderCandidate` | 进入最终工具链路前必须走工具 ABI 和权限门禁 | | permission hook | `PluginPermissionGate::PermissionRequired` 或诊断 | 不能直接批准 | | `tool.execute.before/after` | 当前阶段诊断或 status-only | 不改写工具输入或结果 | @@ -168,7 +168,26 @@ P0-C.1 只读取两个 BitFun 受管目录:用户数据目录的 `plugins` 和 - 具体文件系统校验和信任持久化归 `services-integrations/plugin_source`,`bitfun-core/plugin_source` 只注入产品目录并保留兼容接口。 - 产品路径初始化失败或全局路径管理器已降级到临时目录时,来源列表、审核和 `doctor` 必须返回错误,不得在临时目录中创建信任记录。 - 产品域的 `SourceApproved` 只确认来源内容,不依赖 Host ABI,也不得直接映射为 Host 的 `Trusted`。P0-C.2 首次激活必须展示适配器、入口、能力和副作用并重新确认。 -- P0-C.2 加载器不得复用 CLI 扫描结果直接执行文件;绑定前必须把清单声明文件重新校验并固定为不可变快照,dispatch 前还必须校验来源标识、激活确认和 Host 信任 epoch。 +- 来源服务按包重新校验清单、声明文件和哈希,并返回固定内容的包输入。OpenCode 适配器不得根据包路径再次访问文件系统,也不得直接扫描工作区或用户 OpenCode 目录。 +- 来源服务只为当前 `SourceApproved` 的包返回固定内容输入;输入不携带来源审核状态或审核 epoch。适配器始终按未激活状态处理,当前只读 Host 链路不得产生 custom tool 候选。后续激活流程必须重新确认入口、能力和副作用,并独立定义 Host 信任及其 epoch。 +- 单个来源服务实例串行生成固定内容输入;扫描和稳定性复核共享同一字节与时间预算,返回前再次确认信任 epoch、目标来源身份和 `SourceApproved` 状态。固定输入构造同时限制文件数量、单文件大小、包总量和声明文件集合。 + +```mermaid +sequenceDiagram + participant Assembly as 产品组装 + participant Source as 受管包来源服务 + participant Adapter as OpenCode 适配器 + participant Host as 插件运行时主机 + + Assembly->>Source: 读取指定包 + Source->>Source: 重新校验清单、文件边界与哈希 + Source-->>Assembly: 固定内容的包输入 + Assembly->>Adapter: 创建只读适配器 + Assembly->>Host: PluginRuntimeHost::new(adapter) + Host->>Adapter: read / dispatch + Adapter-->>Host: 来源、诊断和未激活状态 + Note over Adapter,Host: 不执行 JS/TS,不产生工具候选 +``` ## 7. 验证要求 diff --git a/docs/architecture/product-architecture.md b/docs/architecture/product-architecture.md index 388d1bac22..db46c09c1c 100644 --- a/docs/architecture/product-architecture.md +++ b/docs/architecture/product-architecture.md @@ -127,12 +127,12 @@ flowchart TB P0 的目标不是复制完整 OpenCode 运行时,也不是导入用户已有 OpenCode 安装。P0 只验证一条 BitFun 主导的 OpenCode-compatible 插件路径。 -当前 P0-C.1 只建立包识别、完整性校验、工作区信任和 CLI 诊断,不执行插件: +P0-C.1 已建立包识别、完整性校验、工作区信任和 CLI 诊断,不执行插件: 1. 用户级包和项目级包只从 BitFun 受管目录发现;工作区同 ID 来源优先且不得回退到用户级包。 2. `bitfun.plugin.json` 只定义生态无关的包来源标识、适配器标识和文件哈希;具体生态入口由对应适配器解释。 3. `bitfun-cli plugins` 提供来源审核和诊断;`SourceApproved` 只确认当前来源内容,不表示能力已批准、已启用或可执行,完整性错误由 `bitfun-cli doctor` 返回失败。 -4. 当前来源接口未绑定生产插件主机,不执行 JS/TS,不注册最终工具。目录、清单和持久化规则见 [`plugin-runtime-host-design.md`](plugin-runtime-host-design.md#6-目录与来源原则)。 +4. 来源接口尚未绑定生产插件主机,不执行 JS/TS,不注册最终工具。目录、清单和持久化规则见 [`plugin-runtime-host-design.md`](plugin-runtime-host-design.md#6-目录与来源原则)。 归属边界:`product-domains/plugin_source` 定义纯数据和信任规则,`services-integrations/plugin_source` 负责文件系统校验、锁和持久化,`bitfun-core/plugin_source` 仅注入产品目录并保留 CLI 兼容接口。 @@ -141,11 +141,16 @@ flowchart LR UserRoot["用户级 BitFun 插件目录"] --> Discovery["包发现与完整性校验"] WorkspaceRoot["项目级 .bitfun/plugins"] --> Discovery Manifest["bitfun.plugin.json"] --> Discovery - Discovery --> Snapshot["来源与诊断接口"] - Trust["工作区信任存储"] --> Snapshot - CLI["CLI 插件管理与 doctor"] --> Snapshot + Discovery --> SourceView["来源与诊断接口"] + Trust["工作区信任存储"] --> SourceView + CLI["CLI 插件管理与 doctor"] --> SourceView CLI --> Trust - Snapshot -.->|后续生产绑定| Host["插件运行时主机"] + SourceView -->|按包重新校验| PackageInput["不可变包输入"] + PackageInput --> Adapter["OpenCode 适配器"] + Assembly["产品组装根"] -->|创建| Adapter + Adapter -->|注入| Host["插件运行时主机"] + Host -->|封装 client| RuntimeBinding["PluginRuntimeBinding"] + RuntimeBinding --> AgentRuntime["Agent Runtime"] ``` 当前实现与后续能力边界: @@ -154,16 +159,20 @@ flowchart LR |---|---|---| | 用户级、项目级包 | 从两个 BitFun 受管目录发现并校验 | 安装复制、更新、卸载和组织策略 | | 随产品携带包 | 未建立独立扫描根 | 由构建配置、安装器和产品组装提供来源后接入同一校验接口 | -| OpenCode 兼容内容 | 包清单可声明 `opencode_compatible`,来源模块只验证清单声明文件 | OpenCode 适配器解释包内布局;外部目录需独立导入流程 | +| OpenCode 兼容内容 | 包清单可声明 `opencode_compatible`;来源模块重新校验并固定声明文件,适配器只解释该输入 | 外部目录需经独立导入流程转换为受管包 | | 来源审核 | 工作区 `SourceApproved`、`Denied`、`Revoked`;内容变化使旧审核失效,新来源标识回到 `Unknown` | 首次激活能力审核、组织策略、签名和撤销列表 | -| 插件运行 | 不执行 JS/TS,不注册最终工具 | 通过 `PluginRuntimeBinding` 接入主机,再完成工具 ABI 消费路径 | +| 插件运行 | 不执行 JS/TS,不注册最终工具 | 产品组装创建适配器和 Host,再通过 `PluginRuntimeBinding` 注入 Agent Runtime | OpenCode 适配接入规则: -- OpenCode 适配器通过插件运行时主机暴露来源只读视图、诊断和受信任 custom tool 候选映射。 -- 信任输入复用既有 `PluginSourceRef` 来源快照,并携带产品来源/策略侧生成的信任 epoch;信任 epoch 必须与本次 read/dispatch epoch 一致。 +- OpenCode 适配器的公开入口只接收来源服务重新校验并固定的受管包输入,不直接扫描工作区或用户 OpenCode 目录。 +- 来源服务只为当前 `SourceApproved` 的包生成固定内容输入;输入只包含来源标识、清单和声明文件内容,不把来源审核状态或审核 epoch 传入 Host。 +- 固定内容输入只保证结构、大小和哈希自洽,不作为审核凭据。生产组装必须从来源服务取得输入;即使其他进程内调用方构造了有效输入,适配器仍只能返回未激活状态。 +- Host 来源 URI 使用来源模块生成的路径摘要区分用户级包、项目级包和后续其他来源,不暴露原始本地路径。 +- OpenCode 适配器始终按未激活状态处理该输入,只能暴露来源只读视图和诊断;激活确认产生独立的 Host 信任后,才允许映射 custom tool 候选。 +- 当前源码探测只识别经过测试的单行声明形式,不是完整 JS/TS 解析器;空包或没有任何受支持入口的包必须返回诊断,其他 JS/TS 语法不属于本阶段兼容范围。 - 未支持或信任不足的能力必须返回诊断或 `unsupported` 状态,不得因外部插件内容导致运行时崩溃。 -- 生产产品组装接入必须通过 `PluginRuntimeBinding` 注册适配器,并在同一变更中同步边界脚本、主机路径测试和启用/降级策略。 +- 后续生产接入由唯一的产品组装根调用具体适配器工厂,将适配器注入 Host,再把 Host client 封装为 `PluginRuntimeBinding`;同一变更必须同步边界脚本、主机路径测试和启用/降级策略。 - GUI、TUI/CLI、Web 等产品入口只消费能力服务接口、插件只读视图、诊断和稳定状态词,不直接依赖 OpenCode 适配器内部类型或插件主机内部 ABI。 信任 epoch 与生命周期: @@ -171,14 +180,15 @@ OpenCode 适配接入规则: - 来源审核 epoch 由 BitFun 来源与信任模块维护。审核、拒绝、撤销、已有记录的来源标识或哈希变化都会推进 epoch;重复写入相同决定不推进 epoch。信任文件重建时使用新的随机初始值,避免旧 epoch 被重复使用。 - 发现的新来源默认为 `Unknown`;CLI 只允许对当前工作区已发现且 id 唯一的包写入 `SourceApproved`、`Denied` 或 `Revoked`。 - 损坏、版本未知或记录冲突的信任文件按失败处理;适配器和主机不得写信任状态。 -- `SourceApproved` 不得直接映射为 Host 的 `Trusted`。P0-C.2 首次激活必须展示适配器、入口、能力和副作用并重新确认;只有该确认产生的 Host 信任且 epoch 匹配时才可生成 custom tool 候选。 +- `SourceApproved` 不得直接映射为 Host 的 `Trusted`。来源审核 epoch 只属于来源存储;P0-C.2 首次激活必须展示适配器、入口、能力和副作用,并由激活归属模块定义独立的 Host 信任及其 epoch。 - `ProjectionOnly` 在候选路径中表示插件代码没有被执行、最终效果没有提交;它允许主机返回受权限门禁保护的候选项和诊断,不表示插件运行时已经可执行。 OpenCode 能力映射: | OpenCode 能力 | BitFun P0 处理 | 不允许 | |---|---|---| -| project/global plugin config | 可选导入源,产出 provenance、manifest、hash、诊断和候选 BitFun 来源 | 作为 BitFun 主配置或直接决定启用状态 | +| 受管包内的 `opencode.json` | 当前只读解释配置和 npm 插件声明,返回诊断 | 安装或执行 npm 插件、直接决定启用状态 | +| 用户已有 project/global plugin config | 当前未实现;未来由独立导入流程转换为受管包 | 由适配器直接扫描、作为 BitFun 主配置 | | custom tools | 映射为工具提供方候选,最终走工具 ABI | 新增插件专用工具模型 | | permission hooks | 映射为权限候选或需要确认的诊断 | 插件直接批准、拒绝或写审计 | | events / SSE | 订阅 BitFun 公开事件清单的受控子集 | 读取内部 session、turn、tool 或 UI 状态 | diff --git a/docs/plans/core-decomposition-plan.md b/docs/plans/core-decomposition-plan.md index 8e74ffa152..6a80eeb60f 100644 --- a/docs/plans/core-decomposition-plan.md +++ b/docs/plans/core-decomposition-plan.md @@ -22,9 +22,9 @@ - Desktop、CLI、ACP 仍有路径通过 `bitfun-core/product-full` 获取完整产品能力;后续插件主线不能把该状态固化为新入口依赖。 - 工具 ABI、事件清单、运行时服务、智能体运行时、产品能力和插件 `disabled` / `projection-only` 基础边界已存在。 - `runtime-ports` 的插件主机 ABI 已有公开接口预算脚本;后续不能绕过预算新增插件、hook、event、UI 或生态兼容对象。 -- `opencode-adapter` 当前提供来源发现、诊断只读视图和受信任 custom tool 候选映射。 +- `opencode-adapter` 当前解释固定内容的受管包,提供诊断只读视图和 custom tool 候选映射;来源发现归 `services-integrations/plugin_source`。 - `services-integrations/plugin_source` 已提供受管包发现、完整性校验和工作区信任持久化;`bitfun-core/plugin_source` 仅保留路径注入与 CLI 兼容接口。该接口尚未绑定生产 Plugin Runtime Host。 -- 下一阶段只完成受管包来源到 Plugin Runtime Host 和工具 ABI 的真实消费路径,不执行无约束 JS/TS,也不依赖外部 OpenCode CLI。 +- 当前阶段先完成受管包到 Plugin Runtime Host 的只读链路;后续再完成激活确认和工具 ABI 消费,不执行无约束 JS/TS,也不依赖外部 OpenCode CLI。 ## 3. 当前差距 @@ -75,7 +75,7 @@ ### 阶段 P0-C.1:OpenCode-compatible 最小来源与诊断 -状态:实现完成,验收待合入。 +状态:已合入。 目标:只做 BitFun 主导的受管包来源、工作区信任和 CLI 诊断闭环,不执行插件。 @@ -101,6 +101,11 @@ 目标:证明一个 OpenCode-compatible custom tool 可以映射为 BitFun 工具候选;是否进入最终工具链路,仍由工具 ABI、权限门禁和归属模块决定。 +执行顺序: + +1. 先完成受管包到 OpenCode 适配器的固定输入链路。来源服务按包重新校验声明文件,适配器公开入口不再扫描工作区目录;`SourceApproved` 只返回未激活状态和诊断。 +2. 再由独立 PR 完成激活确认、Host 信任和产品组装。该步骤完成前不得生成 custom tool 候选或宣称插件可执行。 + 范围: - custom tool 只映射为提供方候选(`ProviderCandidate`)。 @@ -114,7 +119,7 @@ - 生成最终工具或执行任何工具前,必须继续经过工具快照、权限门禁和归属模块;OpenCode adapter 内不得执行工具。 - 不新增插件专用工具 ABI。 - Desktop / CLI 产品入口只消费能力服务接口、插件只读视图、诊断和稳定状态词,不直接依赖 OpenCode adapter。 -- 后续生产接入必须通过 `PluginRuntimeBinding` 注册插件运行时主机,并同步边界脚本、主机路径测试和启用/降级策略。 +- 后续生产接入由唯一产品组装根创建适配器和插件运行时主机,再将 Host client 封装为 `PluginRuntimeBinding`,并同步边界脚本、主机路径测试和启用/降级策略。 ## 5. 后端复杂度整改清单 diff --git a/scripts/core-boundaries/rules/feature-rules.mjs b/scripts/core-boundaries/rules/feature-rules.mjs index 1acb4da68d..2938d6f605 100644 --- a/scripts/core-boundaries/rules/feature-rules.mjs +++ b/scripts/core-boundaries/rules/feature-rules.mjs @@ -108,7 +108,8 @@ export const optionalDependencyFeatureOwnerRules = [ dependencies: [ { depName: 'dirs', ownerFeatures: ['miniapp'] }, { depName: 'log', ownerFeatures: ['function-agents'] }, - { depName: 'sha2', ownerFeatures: ['miniapp'] }, + { depName: 'hex', ownerFeatures: ['plugin-source'] }, + { depName: 'sha2', ownerFeatures: ['miniapp', 'plugin-source'] }, { depName: 'which', ownerFeatures: ['miniapp'] }, ], }, diff --git a/scripts/core-boundaries/rules/source/forbidden-rules.mjs b/scripts/core-boundaries/rules/source/forbidden-rules.mjs index f1b1aa0bbb..b597f879e6 100644 --- a/scripts/core-boundaries/rules/source/forbidden-rules.mjs +++ b/scripts/core-boundaries/rules/source/forbidden-rules.mjs @@ -28,11 +28,11 @@ export const forbiddenContentRules = [ { path: 'src/crates/services/services-integrations/src/plugin_source.rs', reason: - 'managed plugin source service method surface must stay limited to construction, refresh, and trust review', + 'managed plugin source service method surface must stay limited to construction, refresh, trust review, and one selected-package read', patterns: [ { regex: - /\bpub\s+(?:async\s+)?fn\s+(?!(?:new|refresh|set_trust)\b)[A-Za-z_][A-Za-z0-9_]*\b/, + /\bpub\s+(?:async\s+)?fn\s+(?!(?:new|refresh|set_trust|load_package)\b)[A-Za-z_][A-Za-z0-9_]*\b/, message: 'unexpected public ManagedPluginSourceService method; update the reviewed method budget before exposing more API', }, @@ -45,7 +45,7 @@ export const forbiddenContentRules = [ patterns: [ { regex: - /\bpub\s+(?:const\s+)?fn\s+(?!(?:parse_json|validate|new|epoch|trust_level_for|apply_decision|reconcile_sources)\b)[A-Za-z_][A-Za-z0-9_]*\b/, + /\bpub\s+(?:const\s+)?fn\s+(?!(?:parse_json|validate|content_hash|new|into_parts|epoch|trust_level_for|apply_decision|reconcile_sources)\b)[A-Za-z_][A-Za-z0-9_]*\b/, message: 'unexpected public plugin source contract method; update the reviewed method budget before exposing more API', }, @@ -4123,27 +4123,27 @@ export const forbiddenContentUnderRules = [ { path: 'src', reason: - 'OpenCode adapter must not become a production dependency before reviewed product source wiring', + 'OpenCode adapter must not become a production dependency before reviewed composition-root wiring', patterns: [ { regex: /\b(?:use\s+bitfun_opencode_adapter\b|extern\s+crate\s+bitfun_opencode_adapter\b|bitfun_opencode_adapter::)/, allowPaths: ['src/crates/adapters/opencode-adapter/tests/opencode_source_adapter.rs'], message: - 'production crates must not import bitfun-opencode-adapter directly; integrate OpenCode through the Plugin Runtime Host boundary', + 'only a reviewed product composition root may import bitfun-opencode-adapter and inject it into Plugin Runtime Host', }, ], }, { path: 'BitFun-Installer/src-tauri', reason: - 'OpenCode adapter must not become a production dependency before reviewed product source wiring', + 'OpenCode adapter must not become a production dependency before reviewed composition-root wiring', patterns: [ { regex: /\b(?:use\s+bitfun_opencode_adapter\b|extern\s+crate\s+bitfun_opencode_adapter\b|bitfun_opencode_adapter::)/, message: - 'production crates must not import bitfun-opencode-adapter directly; integrate OpenCode through the Plugin Runtime Host boundary', + 'only a reviewed product composition root may import bitfun-opencode-adapter and inject it into Plugin Runtime Host', }, ], }, diff --git a/scripts/core-boundaries/rules/source/public-api-rules.mjs b/scripts/core-boundaries/rules/source/public-api-rules.mjs index 7c3113c03e..e2d466596b 100644 --- a/scripts/core-boundaries/rules/source/public-api-rules.mjs +++ b/scripts/core-boundaries/rules/source/public-api-rules.mjs @@ -161,7 +161,7 @@ function opencodeAdapterEntry(symbol, consumer) { contractSlice: contractSlices.opencodeAdapterBoundary, wireImpact: false, rationale: - 'P0-C needs one adapter factory for host-readable OpenCode-compatible sources; trust input reuses existing PluginSourceRef snapshots plus trust epoch instead of adding an ecosystem DTO', + 'P0-C needs one adapter factory that consumes fixed BitFun-managed package content and returns the existing PluginHostAdapter boundary', exit: 'remove only if source discovery moves behind a reviewed product source registry with equivalent host tests', }; @@ -169,8 +169,8 @@ function opencodeAdapterEntry(symbol, consumer) { export const opencodeAdapterPublicApiEntries = [ opencodeAdapterEntry( - 'load_opencode_workspace_adapter', - 'PluginRuntimeHost::new integration tests with PluginSourceRef trust snapshots; production Product Assembly binding is out of scope for this PR', + 'load_opencode_package_adapter', + 'managed package source service to PluginRuntimeHost integration tests; production Product Assembly binding is out of scope for this PR', ), ]; @@ -180,11 +180,11 @@ function pluginSourceEntry(symbol, owner, consumer, verification, wireImpact) { owner, consumer, verification, - p0: 'P0-C.1 BitFun-managed package discovery, workspace review state, and CLI diagnostics', + p0: 'P0-C managed package discovery, workspace review state, fixed adapter input, and CLI diagnostics', contractSlice: contractSlices.bitfunPluginExtension, wireImpact, rationale: - 'P0-C.1 needs a package identity and review boundary without exposing ecosystem adapter or Host ABI types', + 'P0-C needs one ecosystem-neutral package identity, review, and fixed-content boundary without exposing adapter or Host ABI types', exit: 'remove only after a reviewed package-source owner migration with equivalent CLI and trust-state tests', }; @@ -194,6 +194,7 @@ export const pluginSourceContractPublicApiEntries = [ 'PluginPackageFile', 'PluginPackageManifest', 'PluginPackageSourceIdentity', + 'PluginPackageInput', 'PluginPackageTrustLevel', 'PluginTrustDecision', 'PluginTrustStore', diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index 915c10d79c..fd84e4ebd4 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -956,7 +956,7 @@ export function runManifestParserSelfTest({ ).map((entry) => entry.symbol); if ( opencodeAdapterPublicApiSymbols.join(',') !== - 'load_opencode_workspace_adapter' + 'load_opencode_package_adapter' ) { throw new Error('OpenCode adapter public API budget must stay limited to source adapter loading'); } diff --git a/src/crates/adapters/opencode-adapter/AGENTS-CN.md b/src/crates/adapters/opencode-adapter/AGENTS-CN.md index 9b742f73e1..af54cbc613 100644 --- a/src/crates/adapters/opencode-adapter/AGENTS-CN.md +++ b/src/crates/adapters/opencode-adapter/AGENTS-CN.md @@ -2,8 +2,8 @@ # OpenCode Adapter -本 crate 拥有 OpenCode-compatible 来源发现和受信任候选映射能力。它验证 -`opencode.json` 和 `.opencode/plugins/*.js|ts` 等导入形态,并通过窄 Plugin Runtime Host +本 crate 拥有 OpenCode-compatible 受管包解释和受信任候选映射能力。它验证受管包中的 +`opencode.json` 和 `.opencode/plugins/*.js|ts`,并通过窄 Plugin Runtime Host 主机适配器暴露来源事实、诊断和类型化候选项。它不得拥有产品策略、主机生命周期、 沙箱、界面实现或最终权限/工具结果写入。 @@ -14,7 +14,7 @@ - 导入 `opencode.json`、`.opencode/plugins/*.js|ts` 或未来 OpenCode 全局插件目录时,必须先生成类型化 导入事实、候选 BitFun 插件来源记录、清单、哈希、诊断和信任状态, 这些结果才能交给产品侧启用或执行链路;适配器自身不直接启用或执行。 -- `load_opencode_workspace_adapter` 必须通过既有 `PluginSourceRef` 来源快照和信任 epoch 接收 BitFun 来源信任快照;OpenCode 目录扫描结果不得自行升级为可信。 +- `load_opencode_package_adapter` 只接收固定内容的受管包输入。`SourceApproved` 在 Host 边界仍为未激活状态,没有独立评审的激活路径时不得产生候选项。 - 受信任 custom tool 声明只能映射为提供方候选;生成最终工具、权限结果和审计事实仍由工具 ABI、 权限控制和产品归属路径完成。 - 用户本机是否安装 `opencode` CLI 与加载 OpenCode-compatible 插件无关。与已安装 OpenCode 可执行文件 @@ -24,15 +24,18 @@ - 依赖 `bitfun-runtime-ports` 等稳定接口和 `PluginHostAdapter` 边界 trait,不依赖 `bitfun-core`、app crate、Tauri API、产品界面或具体服务管理器。 -- OpenCode 配置 JSON 导入和工作区插件导入解析保留在本 crate 内。跨 crate - 输出必须通过 `load_opencode_workspace_adapter` 和 Plugin Runtime Host DTO,不得把 OpenCode 原始 JSON +- OpenCode 配置 JSON 和插件源码解析保留在本 crate 内。跨 crate + 输出必须通过 `load_opencode_package_adapter` 和 Plugin Runtime Host DTO,不得把 OpenCode 原始 JSON 或源码语法暴露为产品接口或稳定结构化对象。 +- 当前源码探测只识别测试覆盖的声明式语法子集,不是通用 JS/TS 解析器;没有可识别入口的包和已识别但不支持的 hook + 必须返回诊断,其他语法不属于当前兼容范围。 - 未支持的 OpenCode 能力必须显式返回类型化诊断或不支持状态,不得静默忽略。 -- 当前公开接口预算只允许 `load_opencode_workspace_adapter`。新增或修改公开入口签名/语义必须同步预算、 +- 当前公开接口预算只允许 `load_opencode_package_adapter`。新增或修改公开入口签名/语义必须同步预算、 当前消费方和聚焦主机路径测试。 - 本 crate 可以提供私有 OpenCode 兼容导入映射器和验证样例用于适配器验证; - 公开入口仍限制为 `load_opencode_workspace_adapter`,并由 Plugin Runtime Host 调用。 -- 本 PR 不包含生产产品组装接入。后续如需注册该适配器,必须在同一变更中同步边界脚本和聚焦主机路径测试。 + 公开入口仍限制为 `load_opencode_package_adapter`,由经过评审的产品组装根调用,再把返回的适配器注入 Plugin Runtime Host。 +- 本 PR 不包含生产产品组装接入。后续唯一产品组装根可以依赖本 crate 创建适配器, + 但必须在同一变更中同步边界脚本和聚焦主机路径测试。 - 生产 crate 不得直接依赖 `bitfun_opencode_adapter` 内部类型。未支持能力必须诊断化, 不得因外部插件内容导致运行时崩溃。 diff --git a/src/crates/adapters/opencode-adapter/AGENTS.md b/src/crates/adapters/opencode-adapter/AGENTS.md index 5807844c43..3ad99e51cf 100644 --- a/src/crates/adapters/opencode-adapter/AGENTS.md +++ b/src/crates/adapters/opencode-adapter/AGENTS.md @@ -2,9 +2,9 @@ # OpenCode Adapter -This crate owns OpenCode-compatible source discovery and trust-gated candidate -mapping. It validates OpenCode import shapes such as `opencode.json` and -`.opencode/plugins/*.js|ts`, then exposes source facts, diagnostics, and typed +This crate owns OpenCode-compatible package interpretation and trust-gated +candidate mapping. It validates managed package content such as `opencode.json` +and `.opencode/plugins/*.js|ts`, then exposes source facts, diagnostics, and typed effect candidates through a narrow Plugin Runtime Host adapter. It must not own product policy, host lifecycle, sandboxing, UI implementation, or effect result writes. @@ -19,9 +19,9 @@ Product-source boundary: plugin source records, manifests, hashes, diagnostics, and trust state before those facts can enter the product-side enablement or execution path. The adapter itself must not enable or execute plugins. -- `load_opencode_workspace_adapter` must receive BitFun source trust snapshots - through existing `PluginSourceRef` values plus a trust epoch; OpenCode - directory scanning must not promote sources to trusted on its own. +- `load_opencode_package_adapter` receives only fixed managed package content. + `SourceApproved` remains untrusted at the Host boundary and must not produce + candidates without a separately reviewed activation path. - Trusted custom tool declarations may only be mapped as provider candidates; final tool creation, permission decisions, and audit facts must stay in the tool ABI, permission, and product owner path. @@ -34,22 +34,26 @@ Product-source boundary: - Depend on stable contracts such as `bitfun-runtime-ports` and the `PluginHostAdapter` boundary trait, not `bitfun-core`, app crates, Tauri APIs, product UI, or concrete service managers. -- Keep OpenCode config JSON import and workspace plugin import parsing inside - this crate. Cross-crate outputs must go through `load_opencode_workspace_adapter` +- Keep OpenCode config JSON and plugin source parsing inside this crate. + Cross-crate outputs must go through `load_opencode_package_adapter` and Plugin Runtime Host DTOs; do not expose raw OpenCode JSON or source syntax as product contracts. +- Current source inspection recognizes only the tested declarative subset. It is + not a general JavaScript or TypeScript parser. Packages with no recognized + entry and recognized unsupported hooks must produce diagnostics; other syntax + is outside the current compatibility claim. - Unsupported OpenCode capabilities must be explicit diagnostics or typed unsupported candidates. Do not silently ignore them. -- The public API budget is limited to `load_opencode_workspace_adapter`. New +- The public API budget is limited to `load_opencode_package_adapter`. New public symbols or changes to public entry signatures and semantics require an updated public API budget, current consumer, and focused host-path tests. - This crate may provide private OpenCode compatibility import projectors and fixtures for adapter verification. The public entry remains limited to - `load_opencode_workspace_adapter`, called through Plugin Runtime Host. + `load_opencode_package_adapter`, called by the reviewed product composition + root before the returned adapter is injected into Plugin Runtime Host. - This PR keeps production Product Assembly wiring out of scope. A future - reviewed registration path may call the public factory through Plugin Runtime - Host, but must update boundary guards and focused host-path tests in the same - change. + reviewed composition root may depend on this crate to create the adapter, but + must update boundary guards and focused host-path tests in the same change. - Production crates must not depend on `bitfun_opencode_adapter` internals. Unsupported capabilities must return diagnostics or typed unsupported states instead of failing at runtime on external plugin content. diff --git a/src/crates/adapters/opencode-adapter/Cargo.toml b/src/crates/adapters/opencode-adapter/Cargo.toml index ce8eeba4f7..aadb927ad3 100644 --- a/src/crates/adapters/opencode-adapter/Cargo.toml +++ b/src/crates/adapters/opencode-adapter/Cargo.toml @@ -12,14 +12,17 @@ crate-type = ["rlib"] [dependencies] async-trait = { workspace = true } bitfun-plugin-runtime-host = { path = "../../execution/plugin-runtime-host" } +bitfun-product-domains = { path = "../../contracts/product-domains", default-features = false, features = ["plugin-source"] } bitfun-runtime-ports = { path = "../../contracts/runtime-ports" } hex = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } thiserror = { workspace = true } +urlencoding = { workspace = true } [dev-dependencies] +bitfun-services-integrations = { path = "../../services/services-integrations", default-features = false, features = ["plugin-source"] } tokio = { workspace = true } [lints] diff --git a/src/crates/adapters/opencode-adapter/src/lib.rs b/src/crates/adapters/opencode-adapter/src/lib.rs index aa34b7493e..a523bca3c0 100644 --- a/src/crates/adapters/opencode-adapter/src/lib.rs +++ b/src/crates/adapters/opencode-adapter/src/lib.rs @@ -1,11 +1,12 @@ //! OpenCode-compatible plugin adapter. //! //! The production surface is intentionally small: load OpenCode-compatible -//! workspace sources plus existing `PluginSourceRef` trust snapshots as a Plugin -//! Runtime Host adapter that exposes source facts, diagnostics, and trust-gated -//! provider candidates. It does not execute JavaScript, install npm packages, -//! or depend on a user-local `opencode` CLI. +//! managed package content as a Plugin Runtime Host adapter that exposes source +//! facts, diagnostics, and an unactivated status. Candidate mapping remains +//! private until a separately reviewed Host activation path exists. The adapter +//! does not execute JavaScript, install npm packages, or depend on a user-local +//! `opencode` CLI. mod source_adapter; -pub use source_adapter::load_opencode_workspace_adapter; +pub use source_adapter::load_opencode_package_adapter; diff --git a/src/crates/adapters/opencode-adapter/src/source_adapter.rs b/src/crates/adapters/opencode-adapter/src/source_adapter.rs index 9dca4316ed..37a83b551d 100644 --- a/src/crates/adapters/opencode-adapter/src/source_adapter.rs +++ b/src/crates/adapters/opencode-adapter/src/source_adapter.rs @@ -6,6 +6,7 @@ use async_trait::async_trait; use bitfun_plugin_runtime_host::PluginHostAdapter; +use bitfun_product_domains::plugin_source::PluginPackageInput; use bitfun_runtime_ports::{ PermissionPromptDenyState, PermissionPromptDescriptor, PermissionPromptEffectKind, PluginArtifactRef, PluginCapabilityRef, PluginDataClassification, PluginEffectCandidatePayload, @@ -23,11 +24,7 @@ use bitfun_runtime_ports::{ }; use serde::Deserialize; use sha2::{Digest, Sha256}; -use std::{ - fs, - path::{Path, PathBuf}, - sync::Arc, -}; +use std::{collections::HashSet, path::Path, sync::Arc}; const OPENCODE_ADAPTER_ID: &str = "opencode-compatible"; const OPENCODE_CONFIG_SCHEMA: &str = "https://opencode.ai/config.json"; @@ -37,8 +34,7 @@ const CUSTOM_TOOL_CONTRACT_ID: &str = "opencode.custom-tool.v1"; const CUSTOM_TOOL_CAPABILITY_ID: &str = "opencode.custom_tool"; const CUSTOM_TOOL_CAPABILITY_OWNER_ID: &str = "opencode.custom-tools"; const CUSTOM_TOOL_EXTENSION_POINT: &str = "tool"; -const OPENCODE_WORKSPACE_PLUGIN_DIR: &str = ".opencode/plugins"; -const MAX_OPENCODE_PLUGIN_SOURCE_BYTES: u64 = 1_048_576; +const MAX_PLUGIN_ID_COMPONENT_LEN: usize = 40; const UNSUPPORTED_HOOK_EVENTS: &[&str] = &[ "command.executed", @@ -82,85 +78,117 @@ struct OpenCodePluginHostAdapter { } impl OpenCodePluginHostAdapter { - fn from_workspace( - project_root: impl AsRef, - observed_at_ms: u64, - source_trust_epoch: u64, - source_trust_refs: &[PluginSourceRef], - ) -> PortResult { - let project_root = project_root.as_ref(); - let config_path = project_root.join("opencode.json"); - let (config_json, config_uri) = read_opencode_config(&config_path)?; + fn from_package(input: PluginPackageInput, observed_at_ms: u64) -> PortResult { + let (manifest, source, files) = input.into_parts(); + if manifest.adapter != "opencode_compatible" { + return Err(adapter_port_error(format!( + "managed package adapter is not OpenCode-compatible: {}", + manifest.adapter + ))); + } + let provenance_id = sha256_content_hash(&source.source_path) + .trim_start_matches("sha256:") + .to_string(); + let package_path = format!("/managed-plugins/{provenance_id}/{}", source.package_id); + let package_uri = format!( + "bitfun://managed-plugins/{provenance_id}/{}", + urlencoding::encode(&source.package_id) + ); + let config_uri = format!( + "bitfun://managed-plugins/{provenance_id}/{}/opencode.json", + source.package_id + ); let mut projections = Vec::new(); - let config = match parse_opencode_config(&config_json, &config_uri) { - Ok(config) => config, - Err(error) => { - projections.push(OpenCodeProjection::Invalid( - OpenCodeInvalidProjection::config( - &config_uri, - &config_json, - "opencode.config_invalid", - "opencode.json", - error.to_string(), - observed_at_ms, - ), - )); - OpenCodeConfig::empty(config_uri.clone()) - } + let config = match files.get("opencode.json") { + Some(bytes) => match std::str::from_utf8(bytes) { + Ok(config_json) => match parse_opencode_config(config_json, &config_uri) { + Ok(config) => config, + Err(error) => { + projections.push(OpenCodeProjection::Invalid( + OpenCodeInvalidProjection::config( + &config_uri, + config_json, + "opencode.config_invalid", + "opencode.json", + error.to_string(), + observed_at_ms, + ) + .with_package_identity(&source.version, &source.content_hash), + )); + OpenCodeConfig::empty(config_uri.clone()) + } + }, + Err(error) => { + let config_json = String::from_utf8_lossy(bytes); + projections.push(OpenCodeProjection::Invalid( + OpenCodeInvalidProjection::config( + &config_uri, + &config_json, + "opencode.config_invalid", + "opencode.json", + format!("opencode.json must be UTF-8: {error}"), + observed_at_ms, + ) + .with_package_identity(&source.version, &source.content_hash), + )); + OpenCodeConfig::empty(config_uri.clone()) + } + }, + None => OpenCodeConfig::empty(config_uri.clone()), }; - for plugin_path in workspace_plugin_paths(project_root, observed_at_ms)? { - let plugin_path = match plugin_path { - Ok(plugin_path) => plugin_path, - Err(projection) => { - projections.push(OpenCodeProjection::Invalid(projection)); - continue; - } - }; - let plugin_source = fs::read_to_string(&plugin_path).map_err(|error| { - OpenCodeProjection::Invalid(OpenCodeInvalidProjection::local_path( - &plugin_path, - "opencode.local_plugin_unreadable", - "source", - format!( - "failed to read OpenCode plugin source {}: {error}", - plugin_path.display() - ), - observed_at_ms, - )) - }); - let plugin_source = match plugin_source { - Ok(plugin_source) => plugin_source, - Err(projection) => { - projections.push(projection); + for (relative_path, bytes) in files.iter().filter(|(path, _)| { + path.starts_with(".opencode/plugins/") + && (path.ends_with(".js") || path.ends_with(".ts")) + }) { + let plugin_path = format!("{package_path}/{relative_path}"); + let plugin_uri = managed_source_uri(&provenance_id, &source.package_id, relative_path); + let plugin_source = match std::str::from_utf8(bytes) { + Ok(source) => source, + Err(error) => { + projections.push(OpenCodeProjection::Invalid( + OpenCodeInvalidProjection::local_source( + Path::new(&plugin_path), + "", + "opencode.local_plugin_invalid", + "source", + format!("OpenCode plugin source must be UTF-8: {error}"), + observed_at_ms, + ) + .with_package_identity(&source.version, &source.content_hash) + .with_source_uri(plugin_uri.clone()), + )); continue; } }; match OpenCodeSourceProjection::from_local_plugin_source( - &plugin_source, + plugin_source, OpenCodeAdapterSource::project_local( config_uri.clone(), - plugin_path.to_string_lossy().into_owned(), + plugin_path.clone(), PluginTrustLevel::Unknown, observed_at_ms, - ), + ) + .with_source_uri(plugin_uri.clone()), config.clone(), ) - .map(|projection| { - projection - .with_source_trust_refs(source_trust_epoch, source_trust_refs) - .without_config_package_diagnostics() + .map(|mut projection| { + projection.source.version = Some(source.version.clone()); + projection.source.content_hash = source.content_hash.clone(); + projection.without_config_package_diagnostics() }) { Ok(projection) => projections.push(OpenCodeProjection::Local(projection)), Err(error) => projections.push(OpenCodeProjection::Invalid( OpenCodeInvalidProjection::local_source( - &plugin_path, - &plugin_source, + Path::new(&plugin_path), + plugin_source, "opencode.local_plugin_invalid", error.field(), error.to_string(), observed_at_ms, - ), + ) + .with_package_identity(&source.version, &source.content_hash) + .with_source_uri(plugin_uri), )), } } @@ -169,10 +197,28 @@ impl OpenCodePluginHostAdapter { OpenCodeProjection::Package(OpenCodePackageProjection::new( package, &config_uri, + &source.version, + &source.content_hash, observed_at_ms, )) })); + if projections.is_empty() { + projections.push(OpenCodeProjection::Invalid( + OpenCodeInvalidProjection::package( + &package_uri, + &source.package_id, + &source.version, + &source.content_hash, + "opencode.package_no_supported_entry", + "files", + "managed package has no recognized OpenCode config or local plugin entry" + .to_string(), + observed_at_ms, + ), + )); + } + Ok(Self { projections, observed_at_ms, @@ -287,17 +333,13 @@ impl PluginHostAdapter for OpenCodePluginHostAdapter { } } -pub fn load_opencode_workspace_adapter( - project_root: impl AsRef, +pub fn load_opencode_package_adapter( + input: PluginPackageInput, observed_at_ms: u64, - source_trust_epoch: u64, - source_trust_refs: &[PluginSourceRef], ) -> PortResult> { - Ok(Arc::new(OpenCodePluginHostAdapter::from_workspace( - project_root, + Ok(Arc::new(OpenCodePluginHostAdapter::from_package( + input, observed_at_ms, - source_trust_epoch, - source_trust_refs, )?)) } @@ -396,17 +438,28 @@ struct OpenCodePackageProjection { } impl OpenCodePackageProjection { - fn new(package: &str, config_uri: &str, observed_at_ms: u64) -> Self { - let plugin_id = format!("opencode.npm.{}", sanitize_plugin_id_component(package)); + fn new( + package: &str, + config_uri: &str, + package_version: &str, + package_content_hash: &str, + observed_at_ms: u64, + ) -> Self { + let source_uri = format!("{config_uri}#npm={}", urlencoding::encode(package)); + let plugin_id = stable_plugin_id( + "opencode.npm", + &sanitize_plugin_id_component(package), + &source_uri, + ); Self { config_uri: config_uri.to_string(), package: package.to_string(), source: PluginSourceRef { plugin_id: plugin_id.clone(), source_kind: PluginSourceKind::OpenCodeCompatible, - source: format!("npm:{package}"), - version: None, - content_hash: sha256_content_hash(&format!("npm:{package}")), + source: source_uri, + version: Some(package_version.to_string()), + content_hash: package_content_hash.to_string(), trust_level: PluginTrustLevel::Unknown, manifest: Some(PluginManifestRef { manifest_id: format!("{plugin_id}:opencode.config"), @@ -566,26 +619,44 @@ struct OpenCodeInvalidProjection { } impl OpenCodeInvalidProjection { - fn config( - config_uri: &str, - config_json: &str, + fn with_package_identity(mut self, version: &str, content_hash: &str) -> Self { + self.source.version = Some(version.to_string()); + self.source.content_hash = content_hash.to_string(); + self + } + + fn with_source_uri(mut self, source_uri: String) -> Self { + self.source.source = source_uri; + self + } + + fn package( + package_uri: &str, + package_id: &str, + version: &str, + content_hash: &str, code: &str, field: &str, message: String, observed_at_ms: u64, ) -> Self { + let plugin_id = stable_plugin_id( + "opencode.package", + &sanitize_plugin_id_component(package_id), + package_uri, + ); let manifest = PluginManifestRef { - manifest_id: "opencode.config".to_string(), - schema_version: OPENCODE_CONFIG_SCHEMA.to_string(), - path: Some(config_uri.to_string()), + manifest_id: format!("{plugin_id}:bitfun.plugin"), + schema_version: "bitfun.plugin.package.v1".to_string(), + path: Some(package_uri.to_string()), }; Self { source: PluginSourceRef { - plugin_id: "opencode.config".to_string(), + plugin_id, source_kind: PluginSourceKind::OpenCodeCompatible, - source: config_uri.to_string(), - version: None, - content_hash: sha256_content_hash(config_json), + source: package_uri.to_string(), + version: Some(version.to_string()), + content_hash: content_hash.to_string(), trust_level: PluginTrustLevel::Unknown, manifest: Some(manifest.clone()), }, @@ -597,21 +668,36 @@ impl OpenCodeInvalidProjection { } } - fn local_path( - path: &Path, + fn config( + config_uri: &str, + config_json: &str, code: &str, field: &str, message: String, observed_at_ms: u64, ) -> Self { - Self::local( - path, - sha256_content_hash(&format!("{}:{message}", path.to_string_lossy())), - code, - field, - message, + let plugin_id = stable_plugin_id("opencode.config", "source", config_uri); + let manifest = PluginManifestRef { + manifest_id: format!("{plugin_id}:opencode.config"), + schema_version: OPENCODE_CONFIG_SCHEMA.to_string(), + path: Some(config_uri.to_string()), + }; + Self { + source: PluginSourceRef { + plugin_id, + source_kind: PluginSourceKind::OpenCodeCompatible, + source: config_uri.to_string(), + version: None, + content_hash: sha256_content_hash(config_json), + trust_level: PluginTrustLevel::Unknown, + manifest: Some(manifest.clone()), + }, + validation: invalid_validation(field, code, &message), + diagnostic_code: code.to_string(), + diagnostic_message: message, + diagnostic_detail_manifest: manifest, observed_at_ms, - ) + } } fn local_source( @@ -640,7 +726,7 @@ impl OpenCodeInvalidProjection { message: String, observed_at_ms: u64, ) -> Self { - let plugin_id = format!("opencode.local.{}", path_stem_path(path)); + let plugin_id = local_plugin_id(&path.to_string_lossy()); let path_string = path.to_string_lossy().into_owned(); let manifest = PluginManifestRef { manifest_id: format!("{plugin_id}:{OPENCODE_LOCAL_PLUGIN_SCHEMA_VERSION}"), @@ -770,6 +856,7 @@ impl OpenCodeInvalidProjection { struct OpenCodeAdapterSource { config_uri: String, local_plugin_path: String, + source_uri: String, trust_level: PluginTrustLevel, observed_at_ms: u64, } @@ -781,13 +868,20 @@ impl OpenCodeAdapterSource { trust_level: PluginTrustLevel, observed_at_ms: u64, ) -> Self { + let local_plugin_path = local_plugin_path.into(); Self { config_uri: config_uri.into(), - local_plugin_path: local_plugin_path.into(), + source_uri: source_file_uri(&local_plugin_path), + local_plugin_path, trust_level, observed_at_ms, } } + + fn with_source_uri(mut self, source_uri: String) -> Self { + self.source_uri = source_uri; + self + } } #[derive(Debug, Clone)] @@ -795,17 +889,9 @@ struct OpenCodeSourceProjection { config: OpenCodeConfig, local_plugin: OpenCodeLocalPlugin, source: PluginSourceRef, - source_trust_snapshot: Option, - source_trust_conflict: bool, observed_at_ms: u64, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct OpenCodeMatchedTrustSnapshot { - trust_level: PluginTrustLevel, - trust_epoch: u64, -} - impl OpenCodeSourceProjection { fn from_local_plugin_source( local_plugin_source: &str, @@ -818,7 +904,7 @@ impl OpenCodeSourceProjection { let source_ref = PluginSourceRef { plugin_id: local_plugin.plugin_id.clone(), source_kind: PluginSourceKind::OpenCodeCompatible, - source: source_file_uri(&source.local_plugin_path), + source: source.source_uri.clone(), version: None, content_hash: sha256_content_hash(local_plugin_source), trust_level: source.trust_level, @@ -836,34 +922,10 @@ impl OpenCodeSourceProjection { config, local_plugin, source: source_ref, - source_trust_snapshot: None, - source_trust_conflict: false, observed_at_ms: source.observed_at_ms, }) } - fn with_source_trust_refs( - mut self, - source_trust_epoch: u64, - source_trust_refs: &[PluginSourceRef], - ) -> Self { - let matching_refs = source_trust_refs - .iter() - .filter(|trust_ref| source_identity_matches(trust_ref, &self.source)) - .collect::>(); - self.source_trust_conflict = matching_refs - .windows(2) - .any(|window| window[0].trust_level != window[1].trust_level); - - if let Some(trust_ref) = matching_refs.last() { - self.source_trust_snapshot = Some(OpenCodeMatchedTrustSnapshot { - trust_level: trust_ref.trust_level, - trust_epoch: source_trust_epoch, - }); - } - self - } - #[cfg(test)] fn from_opencode_sources( config_json: &str, @@ -879,28 +941,12 @@ impl OpenCodeSourceProjection { &self.source } - fn source_ref_for_epochs(&self, epochs: &PluginRuntimeEpochs) -> PluginSourceRef { - let mut source = self.source.clone(); - source.trust_level = self.effective_trust_level(epochs); - source - } - - fn effective_trust_level(&self, epochs: &PluginRuntimeEpochs) -> PluginTrustLevel { - match self.source_trust_snapshot { - Some(trust_snapshot) if trust_snapshot.trust_epoch == epochs.trust_epoch => { - trust_snapshot.trust_level - } - Some(_) => PluginTrustLevel::Unknown, - None => self.source.trust_level, - } + fn source_ref_for_epochs(&self, _epochs: &PluginRuntimeEpochs) -> PluginSourceRef { + self.source.clone() } - fn trust_epoch_mismatch( - &self, - epochs: &PluginRuntimeEpochs, - ) -> Option { - self.source_trust_snapshot - .filter(|trust_snapshot| trust_snapshot.trust_epoch != epochs.trust_epoch) + fn effective_trust_level(&self, _epochs: &PluginRuntimeEpochs) -> PluginTrustLevel { + self.source.trust_level } fn without_config_package_diagnostics(mut self) -> Self { @@ -971,16 +1017,10 @@ impl OpenCodeSourceProjection { let effective_trust_level = self.effective_trust_level(&envelope.epochs); if effective_trust_level != PluginTrustLevel::Trusted { - let mut diagnostics = Vec::new(); - if let Some(trust_fact) = self.trust_epoch_mismatch(&envelope.epochs) { - diagnostics - .push(self.trust_epoch_mismatch_dispatch_diagnostic(&envelope, trust_fact)); - } - diagnostics.push(self.trust_dispatch_diagnostic(&envelope, effective_trust_level)); return Ok(self.response( &envelope, Vec::new(), - diagnostics, + vec![self.trust_dispatch_diagnostic(&envelope, effective_trust_level)], Self::trust_status_for_level(effective_trust_level).1, )); } @@ -1015,16 +1055,6 @@ impl OpenCodeSourceProjection { fn read_diagnostics(&self, epochs: &PluginRuntimeEpochs) -> Vec { let mut diagnostics = Vec::new(); let source = self.source_ref_for_epochs(epochs); - if let Some(trust_fact) = self.trust_epoch_mismatch(epochs) { - diagnostics.push(self.trust_epoch_mismatch_diagnostic( - source.clone(), - epochs.trust_epoch, - trust_fact, - )); - } - if self.source_trust_conflict { - diagnostics.push(self.trust_ref_conflict_diagnostic(source.clone())); - } if source.trust_level != PluginTrustLevel::Trusted { diagnostics.push(self.trust_diagnostic(source.clone(), source.trust_level)); } @@ -1302,68 +1332,6 @@ impl OpenCodeSourceProjection { diagnostic } - fn trust_epoch_mismatch_diagnostic( - &self, - source: PluginSourceRef, - runtime_trust_epoch: u64, - trust_snapshot: OpenCodeMatchedTrustSnapshot, - ) -> PluginDiagnostic { - PluginDiagnostic { - diagnostic_id: format!("diag:{}:trust_epoch", self.source.plugin_id), - severity: PluginDiagnosticSeverity::Warning, - source, - code: "opencode.trust_epoch_mismatch".to_string(), - message: format!( - "OpenCode trust snapshot epoch {} does not match runtime trust epoch {}; source remains untrusted", - trust_snapshot.trust_epoch, runtime_trust_epoch - ), - detail: PluginDiagnosticDetail::Adapter { - adapter_id: OPENCODE_ADAPTER_ID.to_string(), - }, - audit: PluginAuditRef { - correlation_id: format!("trust:{}", self.source.plugin_id), - event_id: None, - }, - retryable: true, - } - } - - fn trust_ref_conflict_diagnostic(&self, source: PluginSourceRef) -> PluginDiagnostic { - PluginDiagnostic { - diagnostic_id: format!("diag:{}:trust_ref_conflict", self.source.plugin_id), - severity: PluginDiagnosticSeverity::Warning, - source, - code: "opencode.trust_ref_conflict".to_string(), - message: "OpenCode source has multiple trust snapshots with conflicting trust levels; the last snapshot is used".to_string(), - detail: PluginDiagnosticDetail::Adapter { - adapter_id: OPENCODE_ADAPTER_ID.to_string(), - }, - audit: PluginAuditRef { - correlation_id: format!("trust:{}", self.source.plugin_id), - event_id: None, - }, - retryable: false, - } - } - - fn trust_epoch_mismatch_dispatch_diagnostic( - &self, - envelope: &PluginDispatchEnvelope, - trust_snapshot: OpenCodeMatchedTrustSnapshot, - ) -> PluginDiagnostic { - let mut diagnostic = self.trust_epoch_mismatch_diagnostic( - envelope.source.clone(), - envelope.epochs.trust_epoch, - trust_snapshot, - ); - diagnostic.diagnostic_id = format!( - "diag:{}:dispatch:{}:trust_epoch", - self.source.plugin_id, envelope.event_id - ); - diagnostic.audit = audit_ref(envelope); - diagnostic - } - fn custom_tool_capability_mismatch_diagnostic( &self, envelope: &PluginDispatchEnvelope, @@ -1416,6 +1384,7 @@ impl OpenCodeConfig { } let mut npm_plugins = Vec::new(); + let mut seen_packages = HashSet::new(); for package in doc.plugin { let package = package.trim().to_string(); if package.is_empty() { @@ -1424,7 +1393,9 @@ impl OpenCodeConfig { message: "package names must not be empty".to_string(), }); } - npm_plugins.push(package); + if seen_packages.insert(package.clone()) { + npm_plugins.push(package); + } } Ok(Self { @@ -1467,7 +1438,7 @@ impl OpenCodeLocalPlugin { } Ok(Self { - plugin_id: format!("opencode.local.{}", path_stem(path)), + plugin_id: local_plugin_id(path), export_name, custom_tools, unsupported_hooks, @@ -1574,11 +1545,17 @@ fn path_stem(path: &str) -> String { .replace('-', "_") } -fn path_stem_path(path: &Path) -> String { - path.file_stem() - .and_then(|stem| stem.to_str()) - .map(|stem| stem.replace('-', "_")) - .unwrap_or_else(|| "plugin".to_string()) +fn local_plugin_id(path: &str) -> String { + stable_plugin_id( + "opencode.local", + &sanitize_plugin_id_component(&path_stem(path)), + &path.replace('\\', "/"), + ) +} + +fn stable_plugin_id(prefix: &str, component: &str, identity: &str) -> String { + let digest = hex::encode(Sha256::digest(identity.as_bytes())); + format!("{prefix}.{component}.{}", &digest[..32]) } fn sha256_content_hash(value: &str) -> String { @@ -1587,24 +1564,6 @@ fn sha256_content_hash(value: &str) -> String { format!("sha256:{}", hex::encode(hasher.finalize())) } -fn read_opencode_config(config_path: &Path) -> PortResult<(String, String)> { - let config_uri = file_uri(config_path); - if !config_path.exists() { - return Ok(( - format!(r#"{{"$schema":"{OPENCODE_CONFIG_SCHEMA}","plugin":[]}}"#), - config_uri, - )); - } - - let config_json = fs::read_to_string(config_path).map_err(|error| { - adapter_port_error(format!( - "failed to read OpenCode config {}: {error}", - config_path.display() - )) - })?; - Ok((config_json, config_uri)) -} - fn parse_opencode_config( config_json: &str, config_uri: &str, @@ -1615,172 +1574,6 @@ fn parse_opencode_config( Ok(config) } -fn workspace_plugin_paths( - project_root: &Path, - observed_at_ms: u64, -) -> PortResult>> { - let plugin_dir = project_root.join(OPENCODE_WORKSPACE_PLUGIN_DIR); - if !plugin_dir.exists() { - return Ok(Vec::new()); - } - let plugin_dir_metadata = fs::symlink_metadata(&plugin_dir).map_err(|error| { - adapter_port_error(format!( - "failed to inspect OpenCode plugin directory {}: {error}", - plugin_dir.display() - )) - })?; - if is_unsupported_link(&plugin_dir_metadata) { - return Ok(vec![Err(OpenCodeInvalidProjection::local_path( - &plugin_dir, - "opencode.local_plugin_directory_link_unsupported", - "source", - format!( - "OpenCode plugin directory is a symlink or reparse point and is not scanned by this OpenCode adapter: {}", - plugin_dir.display() - ), - observed_at_ms, - ))]); - } - let canonical_plugin_dir = plugin_dir.canonicalize().map_err(|error| { - adapter_port_error(format!( - "failed to resolve OpenCode plugin directory {}: {error}", - plugin_dir.display() - )) - })?; - - let mut paths = fs::read_dir(&plugin_dir) - .map_err(|error| { - adapter_port_error(format!( - "failed to read OpenCode plugin directory {}: {error}", - plugin_dir.display() - )) - })? - .map(|entry| entry.map(|entry| entry.path())) - .collect::, _>>() - .map_err(|error| { - adapter_port_error(format!( - "failed to inspect OpenCode plugin directory {}: {error}", - plugin_dir.display() - )) - })?; - - paths.sort(); - let candidates = paths - .into_iter() - .filter(|path| { - matches!( - path.extension().and_then(|extension| extension.to_str()), - Some("js" | "ts") - ) - }) - .map(|path| plugin_file_candidate(path, &canonical_plugin_dir, observed_at_ms)) - .collect(); - Ok(candidates) -} - -fn plugin_file_candidate( - path: PathBuf, - canonical_plugin_dir: &Path, - observed_at_ms: u64, -) -> Result { - let metadata = fs::symlink_metadata(&path).map_err(|error| { - OpenCodeInvalidProjection::local_path( - &path, - "opencode.local_plugin_unreadable", - "source", - format!( - "failed to inspect OpenCode plugin source {}: {error}", - path.display() - ), - observed_at_ms, - ) - })?; - - if is_unsupported_link(&metadata) { - return Err(OpenCodeInvalidProjection::local_path( - &path, - "opencode.local_plugin_symlink_unsupported", - "source", - format!( - "OpenCode plugin source is a symlink and is not scanned by this OpenCode adapter: {}", - path.display() - ), - observed_at_ms, - )); - } - if !metadata.is_file() { - return Err(OpenCodeInvalidProjection::local_path( - &path, - "opencode.local_plugin_not_file", - "source", - format!( - "OpenCode plugin source is not a regular file and is not scanned by this OpenCode adapter: {}", - path.display() - ), - observed_at_ms, - )); - } - if metadata.len() > MAX_OPENCODE_PLUGIN_SOURCE_BYTES { - return Err(OpenCodeInvalidProjection::local_path( - &path, - "opencode.local_plugin_too_large", - "source", - format!( - "OpenCode plugin source exceeds the OpenCode adapter size limit of {MAX_OPENCODE_PLUGIN_SOURCE_BYTES} bytes: {}", - path.display() - ), - observed_at_ms, - )); - } - - let canonical_path = path.canonicalize().map_err(|error| { - OpenCodeInvalidProjection::local_path( - &path, - "opencode.local_plugin_unreadable", - "source", - format!( - "failed to resolve OpenCode plugin source {}: {error}", - path.display() - ), - observed_at_ms, - ) - })?; - if !canonical_path.starts_with(canonical_plugin_dir) { - return Err(OpenCodeInvalidProjection::local_path( - &path, - "opencode.local_plugin_outside_workspace", - "source", - format!( - "OpenCode plugin source resolves outside .opencode/plugins and is not scanned: {}", - path.display() - ), - observed_at_ms, - )); - } - - Ok(path) -} - -fn is_unsupported_link(metadata: &fs::Metadata) -> bool { - metadata.file_type().is_symlink() || is_windows_reparse_point(metadata) -} - -#[cfg(windows)] -fn is_windows_reparse_point(metadata: &fs::Metadata) -> bool { - use std::os::windows::fs::MetadataExt; - const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; - metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 -} - -#[cfg(not(windows))] -fn is_windows_reparse_point(_metadata: &fs::Metadata) -> bool { - false -} - -fn file_uri(path: &Path) -> String { - source_file_uri(&path.to_string_lossy()) -} - fn source_file_uri(path: &str) -> String { let normalized = path.replace('\\', "/"); if normalized.starts_with('/') { @@ -1790,10 +1583,25 @@ fn source_file_uri(path: &str) -> String { } } +fn managed_source_uri(provenance_id: &str, package_id: &str, relative_path: &str) -> String { + let encoded_path = relative_path + .split('/') + .map(|segment| urlencoding::encode(segment).into_owned()) + .collect::>() + .join("/"); + format!( + "bitfun://managed-plugins/{provenance_id}/{}/{encoded_path}", + urlencoding::encode(package_id) + ) +} + fn sanitize_plugin_id_component(value: &str) -> String { - let mut sanitized = String::with_capacity(value.len()); + let mut sanitized = String::with_capacity(value.len().min(MAX_PLUGIN_ID_COMPONENT_LEN)); let mut previous_separator = false; for ch in value.chars() { + if sanitized.len() >= MAX_PLUGIN_ID_COMPONENT_LEN { + break; + } if ch.is_ascii_alphanumeric() { sanitized.push(ch.to_ascii_lowercase()); previous_separator = false; @@ -1913,6 +1721,7 @@ mod opencode_projection_contracts { #[test] fn projects_real_opencode_config_and_local_plugin_source() { let adapter = adapter(PluginTrustLevel::Trusted); + let plugin_id = adapter.source.plugin_id.clone(); assert_eq!( adapter.config.npm_plugins, @@ -1930,17 +1739,14 @@ mod opencode_projection_contracts { request_id: "read-1".to_string(), project_domain_id: "project-1".to_string(), workspace_id: "workspace-1".to_string(), - plugin_ids: vec!["opencode.local.workspace_tools".to_string()], + plugin_ids: vec![plugin_id.clone()], include_config_validation: true, epochs: epochs(), }) .expect("project read model"); assert_eq!(response.sources.len(), 1); - assert_eq!( - response.sources[0].plugin_id, - "opencode.local.workspace_tools" - ); + assert_eq!(response.sources[0].plugin_id, plugin_id); assert_eq!( response.sources[0].source_kind, PluginSourceKind::OpenCodeCompatible @@ -1976,15 +1782,14 @@ mod opencode_projection_contracts { #[test] fn p0_c2_fixture_projects_custom_tool_candidate_with_permission_prompt() { let adapter = adapter(PluginTrustLevel::Trusted); + let plugin_id = adapter.source.plugin_id.clone(); + let provider_id = format!("{plugin_id}.workspaceSummary"); let response = adapter .project_dispatch_response(envelope(&adapter, CUSTOM_TOOL_EXTENSION_POINT)) .expect("project dispatch response"); assert_eq!(response.adapter_id, OPENCODE_ADAPTER_ID); - assert_eq!( - response.plugin_id.as_deref(), - Some("opencode.local.workspace_tools") - ); + assert_eq!(response.plugin_id.as_deref(), Some(plugin_id.as_str())); assert_eq!(response.effects.len(), 1); assert!(response.diagnostics.is_empty()); assert_eq!( @@ -2003,14 +1808,8 @@ mod opencode_projection_contracts { effect.declared_capability.capability_id, "opencode.custom_tool" ); - assert_eq!( - effect.target_ref.target_id, - "opencode.local.workspace_tools.workspaceSummary" - ); - assert_eq!( - effect.source_ref.plugin_id, - "opencode.local.workspace_tools" - ); + assert_eq!(effect.target_ref.target_id, provider_id); + assert_eq!(effect.source_ref.plugin_id, plugin_id); assert!(effect.source_ref.content_hash.starts_with("sha256:")); assert_eq!( effect.data_classification, @@ -2025,7 +1824,7 @@ mod opencode_projection_contracts { } => { assert_eq!( provider_id, - "opencode.local.workspace_tools.workspaceSummary" + &format!("{}.workspaceSummary", effect.source_ref.plugin_id) ); assert_eq!(tool_contract_id, "opencode.custom-tool.v1"); } @@ -2034,15 +1833,12 @@ mod opencode_projection_contracts { match &effect.permission { PluginPermissionGate::PermissionRequired { prompt } => { - assert_eq!(prompt.plugin.plugin_id, "opencode.local.workspace_tools"); + assert_eq!(prompt.plugin.plugin_id, effect.source_ref.plugin_id); assert_eq!( prompt.requested_effect, PermissionPromptEffectKind::ProviderCandidate ); - assert_eq!( - prompt.target.target_id, - "opencode.local.workspace_tools.workspaceSummary" - ); + assert_eq!(prompt.target.target_id, effect.target_ref.target_id); assert_eq!(prompt.owner.kind, PluginOwnerKind::ExtensionContract); assert_eq!( prompt.deny_state, @@ -2057,6 +1853,7 @@ mod opencode_projection_contracts { #[tokio::test] async fn host_path_projects_trusted_custom_tool_candidate_with_permission_prompt() { let adapter = adapter(PluginTrustLevel::Trusted); + let plugin_id = adapter.source.plugin_id.clone(); let dispatch = envelope(&adapter, CUSTOM_TOOL_EXTENSION_POINT); let host_adapter: Arc = Arc::new(OpenCodePluginHostAdapter { projections: vec![OpenCodeProjection::Local(adapter)], @@ -2070,10 +1867,7 @@ mod opencode_projection_contracts { .expect("host dispatch should preserve trusted custom tool candidate"); assert_eq!(response.adapter_id, OPENCODE_ADAPTER_ID); - assert_eq!( - response.plugin_id.as_deref(), - Some("opencode.local.workspace_tools") - ); + assert_eq!(response.plugin_id.as_deref(), Some(plugin_id.as_str())); assert_eq!(response.effects.len(), 1); assert!(response.diagnostics.is_empty()); @@ -2087,10 +1881,7 @@ mod opencode_projection_contracts { provider_id, tool_contract_id, } => { - assert_eq!( - provider_id, - "opencode.local.workspace_tools.workspaceSummary" - ); + assert_eq!(provider_id, &format!("{plugin_id}.workspaceSummary")); assert_eq!(tool_contract_id, CUSTOM_TOOL_CONTRACT_ID); } other => panic!("expected provider candidate, got {other:?}"), @@ -2103,7 +1894,7 @@ mod opencode_projection_contracts { ); assert_eq!( prompt.target.target_id, - "opencode.local.workspace_tools.workspaceSummary" + format!("{plugin_id}.workspaceSummary") ); assert_eq!( prompt.deny_state, @@ -2365,13 +2156,14 @@ mod opencode_projection_contracts { #[test] fn untrusted_source_stays_readable_but_projects_no_effects() { let adapter = adapter(PluginTrustLevel::Unknown); + let plugin_id = adapter.source.plugin_id.clone(); let read = adapter .project_read_model(PluginRuntimeReadRequest { request_id: "read-trust".to_string(), project_domain_id: "project-1".to_string(), workspace_id: "workspace-1".to_string(), - plugin_ids: vec!["opencode.local.workspace_tools".to_string()], + plugin_ids: vec![plugin_id], include_config_validation: true, epochs: epochs(), }) @@ -2401,6 +2193,7 @@ mod opencode_projection_contracts { #[test] fn unsupported_opencode_hook_projects_typed_diagnostic_without_effect() { let adapter = adapter(PluginTrustLevel::Trusted); + let plugin_id = adapter.source.plugin_id.clone(); let response = adapter .project_dispatch_response(envelope(&adapter, "tool.execute.before")) .expect("project dispatch response"); @@ -2410,10 +2203,7 @@ mod opencode_projection_contracts { response.diagnostics[0].code, "opencode.hook_projection_only" ); - assert_eq!( - response.diagnostics[0].source.plugin_id, - "opencode.local.workspace_tools" - ); + assert_eq!(response.diagnostics[0].source.plugin_id, plugin_id); assert_eq!( response.plugin_statuses[0].status, PluginStatusKind::ProjectionOnly diff --git a/src/crates/adapters/opencode-adapter/tests/opencode_source_adapter.rs b/src/crates/adapters/opencode-adapter/tests/opencode_source_adapter.rs index cc473d51c1..dea3a6a0b1 100644 --- a/src/crates/adapters/opencode-adapter/tests/opencode_source_adapter.rs +++ b/src/crates/adapters/opencode-adapter/tests/opencode_source_adapter.rs @@ -1,610 +1,554 @@ -use bitfun_opencode_adapter::load_opencode_workspace_adapter; +use bitfun_opencode_adapter::load_opencode_package_adapter; use bitfun_plugin_runtime_host::PluginRuntimeHost; +use bitfun_product_domains::plugin_source::PluginPackageInput; use bitfun_runtime_ports::{ - PluginCapabilityRef, PluginDataClassification, PluginDispatchEnvelope, - PluginEffectCandidatePayload, PluginOwnerKind, PluginOwnerRef, PluginPayloadRedaction, - PluginPayloadRef, PluginRuntimeAvailability, PluginRuntimeClient, PluginRuntimeEpochs, - PluginRuntimeReadRequest, PluginRuntimeUnavailableReason, PluginSourceKind, PluginSourceRef, - PluginStatusKind, PluginTrustLevel, + PluginCapabilityRef, PluginDataClassification, PluginDispatchEnvelope, PluginOwnerKind, + PluginOwnerRef, PluginPayloadRedaction, PluginPayloadRef, PluginRuntimeClient, + PluginRuntimeEpochs, PluginRuntimeReadRequest, PluginStatusKind, PluginTrustLevel, }; -use std::{ - fs, - path::{Path, PathBuf}, - process, - time::{SystemTime, UNIX_EPOCH}, +use bitfun_services_integrations::plugin_source::{ + ManagedPluginSourceService, ManagedPluginTrustDecision, }; +use sha2::{Digest, Sha256}; +use std::{fs, path::PathBuf, process, time::SystemTime}; -const CONFIG: &str = include_str!("fixtures/opencode-example/opencode.json"); -const LOCAL_PLUGIN_SOURCE: &str = +const PLUGIN_SOURCE: &str = include_str!("fixtures/opencode-example/.opencode/plugins/workspace-tools.ts"); -struct TempProject { +struct ManagedPackageFixture { root: PathBuf, + workspace: PathBuf, + package: PathBuf, + service: ManagedPluginSourceService, } -impl TempProject { - fn new(name: &str) -> Self { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system clock should be after unix epoch") - .as_nanos(); - let root = - std::env::temp_dir().join(format!("bitfun-opencode-{name}-{}-{nanos}", process::id())); - fs::create_dir_all(&root).expect("create temp project"); - Self { root } - } - - fn path(&self) -> &Path { - &self.root +impl ManagedPackageFixture { + fn new(name: &str, source: &str) -> Self { + Self::new_with_path(name, source, ".opencode/plugins/workspace-tools.ts") } - fn write_opencode_fixture(&self) { - fs::write(self.root.join("opencode.json"), CONFIG).expect("write opencode config"); - let plugin_dir = self.root.join(".opencode").join("plugins"); - fs::create_dir_all(&plugin_dir).expect("create opencode plugin directory"); - fs::write(plugin_dir.join("workspace-tools.ts"), LOCAL_PLUGIN_SOURCE) - .expect("write local opencode plugin"); + fn new_with_path(name: &str, source: &str, relative_path: &str) -> Self { + let nonce = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .expect("system time") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "bitfun-opencode-managed-{name}-{}-{nonce}", + process::id() + )); + let workspace = root.join("workspace"); + let user_data = root.join("user"); + let package = workspace.join(".bitfun/plugins/acme.demo"); + let plugin_path = package.join(relative_path.replace('/', std::path::MAIN_SEPARATOR_STR)); + fs::create_dir_all(plugin_path.parent().expect("plugin parent")).expect("create package"); + fs::create_dir_all(user_data.join("plugins")).expect("create user package root"); + + fs::write(&plugin_path, source).expect("write plugin source"); + let hash = format!("sha256:{}", hex::encode(Sha256::digest(source.as_bytes()))); + let manifest = serde_json::json!({ + "schemaVersion": 1, + "id": "acme.demo", + "version": "1.0.0", + "adapter": "opencode_compatible", + "files": [{ + "path": relative_path, + "sha256": hash + }] + }); + fs::write( + package.join("bitfun.plugin.json"), + serde_json::to_vec_pretty(&manifest).expect("serialize manifest"), + ) + .expect("write manifest"); + + let service = ManagedPluginSourceService::new( + user_data.join("plugins"), + user_data.clone(), + workspace.join(".bitfun/plugins"), + workspace.clone(), + user_data.join("runtime/plugin-trust.json"), + ); + Self { + root, + workspace, + package, + service, + } } - fn write_opencode_config(&self) { - fs::write(self.root.join("opencode.json"), CONFIG).expect("write opencode config"); - fs::create_dir_all(self.root.join(".opencode").join("plugins")) - .expect("create opencode plugin directory"); + fn add_opencode_config(&self, config: &str) { + self.add_declared_file("opencode.json", config.as_bytes()); } - fn write_plugin(&self, name: &str, source: &str) { + fn add_declared_file(&self, relative_path: &str, content: &[u8]) { + let file_path = self + .package + .join(relative_path.replace('/', std::path::MAIN_SEPARATOR_STR)); + fs::create_dir_all(file_path.parent().expect("declared file parent")) + .expect("create declared file parent"); + fs::write(&file_path, content).expect("write declared file"); + let manifest_path = self.package.join("bitfun.plugin.json"); + let mut manifest: serde_json::Value = + serde_json::from_slice(&fs::read(&manifest_path).expect("read plugin manifest")) + .expect("parse plugin manifest"); + manifest["files"] + .as_array_mut() + .expect("manifest files") + .push(serde_json::json!({ + "path": relative_path, + "sha256": format!( + "sha256:{}", + hex::encode(Sha256::digest(content)) + ) + })); fs::write( - self.root.join(".opencode").join("plugins").join(name), - source, + manifest_path, + serde_json::to_vec_pretty(&manifest).expect("serialize plugin manifest"), ) - .expect("write opencode plugin"); + .expect("update plugin manifest"); + } + + async fn approved_input(&self) -> bitfun_product_domains::plugin_source::PluginPackageInput { + self.service.refresh(&self.workspace).await; + self.service + .set_trust( + &self.workspace, + "acme.demo", + ManagedPluginTrustDecision::ApproveSource, + ) + .await + .expect("approve package source"); + self.service + .load_package(&self.workspace, "acme.demo") + .await + .expect("load fixed package input") } } -impl Drop for TempProject { +impl Drop for ManagedPackageFixture { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.root); } } #[tokio::test] -async fn discovers_opencode_sources_through_plugin_runtime_host() { - let project = TempProject::new("read-model"); - project.write_opencode_fixture(); - - let adapter = - load_opencode_workspace_adapter(project.path(), 1_720_000_001, epochs().trust_epoch, &[]) - .expect("load OpenCode-compatible workspace adapter"); +async fn managed_package_is_read_through_plugin_runtime_host() { + let fixture = ManagedPackageFixture::new("read", PLUGIN_SOURCE); + let input = fixture.approved_input().await; + let adapter = load_opencode_package_adapter(input, 1_720_000_001) + .expect("create OpenCode package adapter"); let host = PluginRuntimeHost::new(adapter); let response = host - .read_plugins(read_request(Vec::new())) + .read_plugins(read_request()) .await - .expect("read plugins through host"); + .expect("read package through host"); - let source_ids = response - .sources - .iter() - .map(|source| source.plugin_id.as_str()) - .collect::>(); - assert_eq!( - source_ids, - [ - "opencode.local.workspace_tools", - "opencode.npm.opencode_wakatime", - "opencode.npm.my_org_custom_plugin" - ] - ); - assert!(response.sources.iter().all(|source| { - source.source_kind == PluginSourceKind::OpenCodeCompatible - && source.trust_level == PluginTrustLevel::Unknown - && source.content_hash.starts_with("sha256:") - })); - - let local_status = response - .plugin_statuses - .iter() - .find(|status| status.source.plugin_id == "opencode.local.workspace_tools") - .expect("local plugin status"); - assert_eq!(local_status.status, PluginStatusKind::TrustRequired); + assert_eq!(response.sources.len(), 1); + assert!(response.sources[0] + .plugin_id + .starts_with("opencode.local.workspace_tools.")); + assert_eq!(response.sources[0].trust_level, PluginTrustLevel::Unknown); assert_eq!( - local_status.availability, - PluginRuntimeAvailability::ProjectionOnly { - reason: PluginRuntimeUnavailableReason::DisabledByPolicy - } + response.plugin_statuses[0].status, + PluginStatusKind::TrustRequired ); - assert!(response .diagnostics .iter() .any(|diagnostic| diagnostic.code == "opencode.trust_required")); - assert!(response - .diagnostics - .iter() - .any(|diagnostic| diagnostic.code == "opencode.npm_plugin_projection_only")); - assert!(response - .diagnostics - .iter() - .any(|diagnostic| diagnostic.code == "opencode.hook_projection_only")); } #[tokio::test] -async fn dispatch_for_discovered_opencode_source_remains_projection_only() { - let project = TempProject::new("dispatch-model"); - project.write_opencode_fixture(); - - let adapter = - load_opencode_workspace_adapter(project.path(), 1_720_000_001, epochs().trust_epoch, &[]) - .expect("load OpenCode-compatible workspace adapter"); +async fn source_approval_does_not_create_custom_tool_candidate() { + let fixture = ManagedPackageFixture::new("approval", PLUGIN_SOURCE); + let input = fixture.approved_input().await; + let adapter = load_opencode_package_adapter(input, 1_720_000_001) + .expect("create OpenCode package adapter"); let host = PluginRuntimeHost::new(adapter); - let read_response = host - .read_plugins(read_request(vec![ - "opencode.local.workspace_tools".to_string() - ])) + let source = host + .read_plugins(read_request()) .await - .expect("read local plugin"); - let source = read_response + .expect("read package through host") .sources .into_iter() - .find(|source| source.plugin_id == "opencode.local.workspace_tools") - .expect("local plugin source"); + .next() + .expect("plugin source"); let response = host .dispatch(dispatch_envelope(source)) .await - .expect("dispatch projection-only source"); + .expect("dispatch remains readable"); - assert_eq!(response.adapter_id, "opencode-compatible"); - assert_eq!( - response.plugin_id.as_deref(), - Some("opencode.local.workspace_tools") - ); assert!(response.effects.is_empty()); assert_eq!( response.plugin_statuses[0].status, PluginStatusKind::TrustRequired ); - assert!(response - .diagnostics - .iter() - .any(|diagnostic| diagnostic.code == "opencode.trust_required")); } #[tokio::test] -async fn trusted_source_snapshot_enables_custom_tool_candidate_through_host() { - let project = TempProject::new("trusted-candidate"); - project.write_opencode_fixture(); - - let discovery_adapter = - load_opencode_workspace_adapter(project.path(), 1_720_000_001, epochs().trust_epoch, &[]) - .expect("load OpenCode-compatible workspace adapter"); - let discovery_host = PluginRuntimeHost::new(discovery_adapter); - let discovery = discovery_host - .read_plugins(read_request(vec![ - "opencode.local.workspace_tools".to_string() - ])) - .await - .expect("read local plugin source"); - let discovered_source = discovery - .sources - .into_iter() - .find(|source| source.plugin_id == "opencode.local.workspace_tools") - .expect("local plugin source"); - - let source_trust_refs = vec![trust_ref_for_source( - &discovered_source, - PluginTrustLevel::Trusted, - )]; - let adapter = load_opencode_workspace_adapter( - project.path(), - 1_720_000_001, - epochs().trust_epoch, - &source_trust_refs, +async fn fixed_package_input_is_not_re_read_or_executed() { + let fixture = ManagedPackageFixture::new( + "immutable", + "export const Plugin = async () => { throw new Error('must not execute') }", + ); + let input = fixture.approved_input().await; + fs::write( + fixture.package.join(".opencode/plugins/workspace-tools.ts"), + "this is not the reviewed source", ) - .expect("load trusted OpenCode-compatible workspace adapter"); + .expect("replace package file after fixed input was created"); + + let adapter = load_opencode_package_adapter(input, 1_720_000_001) + .expect("adapter reads only fixed input"); let host = PluginRuntimeHost::new(adapter); - let read_response = host - .read_plugins(read_request(vec![ - "opencode.local.workspace_tools".to_string() - ])) + let response = host + .read_plugins(read_request()) .await - .expect("read trusted local plugin source"); - let trusted_source = read_response - .sources - .into_iter() - .find(|source| source.plugin_id == "opencode.local.workspace_tools") - .expect("trusted local plugin source"); + .expect("read fixed package input"); - assert_eq!(trusted_source.trust_level, PluginTrustLevel::Trusted); + assert_eq!(response.sources.len(), 1); + assert!(response + .diagnostics + .iter() + .all(|diagnostic| diagnostic.code != "opencode.local_plugin_unreadable")); +} - let response = host - .dispatch(dispatch_envelope(trusted_source)) - .await - .expect("trusted source should project custom tool candidate"); +#[tokio::test] +async fn package_input_rejects_content_changed_after_source_validation() { + let fixture = ManagedPackageFixture::new("tampered-input", PLUGIN_SOURCE); + let (manifest, source, mut files) = fixture.approved_input().await.into_parts(); + files + .get_mut(".opencode/plugins/workspace-tools.ts") + .expect("declared source") + .extend_from_slice(b"\n// changed after validation"); + + let error = PluginPackageInput::new(manifest, source, files) + .expect_err("package input rejects changed content"); + + assert!(error.to_string().contains("file hash does not match")); +} - assert_eq!(response.effects.len(), 1); - assert!(response.diagnostics.is_empty()); - assert_eq!( - response.plugin_statuses[0].status, - PluginStatusKind::ProjectionOnly +#[tokio::test] +async fn package_input_rejects_forged_manifest_and_file_set() { + let fixture = ManagedPackageFixture::new("forged-input", PLUGIN_SOURCE); + let (mut manifest, source, mut files) = fixture.approved_input().await.into_parts(); + manifest.files.push(manifest.files[0].clone()); + files.insert( + ".opencode/plugins/undeclared.ts".to_string(), + b"export const Undeclared = async () => ({})".to_vec(), ); - match &response.effects[0].payload { - PluginEffectCandidatePayload::ProviderCandidate { - provider_id, - tool_contract_id, - } => { - assert_eq!( - provider_id, - "opencode.local.workspace_tools.workspaceSummary" - ); - assert_eq!(tool_contract_id, "opencode.custom-tool.v1"); - } - other => panic!("expected provider candidate, got {other:?}"), - } + + assert!(PluginPackageInput::new(manifest, source, files).is_err()); + + let (manifest, mut source, files) = fixture.approved_input().await.into_parts(); + source.content_hash = format!("sha256:{}", "0".repeat(64)); + assert!(PluginPackageInput::new(manifest, source, files).is_err()); } #[tokio::test] -async fn mismatched_source_trust_ref_does_not_enable_candidate() { - let project = TempProject::new("mismatched-trust-fact"); - project.write_opencode_fixture(); - - let discovery_adapter = - load_opencode_workspace_adapter(project.path(), 1_720_000_001, epochs().trust_epoch, &[]) - .expect("load OpenCode-compatible workspace adapter"); - let discovery_host = PluginRuntimeHost::new(discovery_adapter); - let discovery = discovery_host - .read_plugins(read_request(vec![ - "opencode.local.workspace_tools".to_string() - ])) +async fn revoked_source_cannot_create_a_new_fixed_package_input() { + let fixture = ManagedPackageFixture::new("revoked-source", PLUGIN_SOURCE); + fixture.approved_input().await; + fixture + .service + .set_trust( + &fixture.workspace, + "acme.demo", + ManagedPluginTrustDecision::Revoked, + ) .await - .expect("read local plugin source"); - let discovered_source = discovery - .sources - .into_iter() - .find(|source| source.plugin_id == "opencode.local.workspace_tools") - .expect("local plugin source"); - let mut mismatched_trust_ref = - trust_ref_for_source(&discovered_source, PluginTrustLevel::Trusted); - mismatched_trust_ref.content_hash = "sha256:stale".to_string(); - - let source_trust_refs = vec![mismatched_trust_ref]; - let adapter = load_opencode_workspace_adapter( - project.path(), - 1_720_000_001, - epochs().trust_epoch, - &source_trust_refs, + .expect("revoke package source"); + + let error = fixture + .service + .load_package(&fixture.workspace, "acme.demo") + .await + .expect_err("revoked package cannot be loaded"); + + assert!(error.to_string().contains("source is not approved")); +} + +#[tokio::test] +async fn corrupted_managed_package_is_reported_as_invalid() { + let fixture = ManagedPackageFixture::new("corrupted-package", PLUGIN_SOURCE); + fixture.approved_input().await; + fs::write( + fixture.package.join(".opencode/plugins/workspace-tools.ts"), + "changed without updating the manifest", ) - .expect("load OpenCode-compatible workspace adapter"); - let host = PluginRuntimeHost::new(adapter); - let read_response = host - .read_plugins(read_request(vec![ - "opencode.local.workspace_tools".to_string() - ])) + .expect("corrupt package source"); + + let error = fixture + .service + .load_package(&fixture.workspace, "acme.demo") .await - .expect("read source after mismatched trust snapshot"); - let source = read_response - .sources - .into_iter() - .find(|source| source.plugin_id == "opencode.local.workspace_tools") - .expect("local plugin source"); + .expect_err("corrupted package cannot be loaded"); - assert_eq!(source.trust_level, PluginTrustLevel::Unknown); + assert!(error.to_string().contains("hash_mismatch")); +} + +#[tokio::test] +async fn invalid_plugin_diagnostic_keeps_managed_package_identity() { + let fixture = ManagedPackageFixture::new( + "invalid-plugin", + "export const BrokenPlugin = async () => ({ name: 'broken' })", + ); + let (manifest, source, files) = fixture.approved_input().await.into_parts(); + let expected_version = source.version.clone(); + let expected_hash = source.content_hash.clone(); + let input = PluginPackageInput::new(manifest, source, files).expect("rebuild package input"); + let adapter = load_opencode_package_adapter(input, 1_720_000_001) + .expect("create adapter for invalid plugin"); + let host = PluginRuntimeHost::new(adapter); let response = host - .dispatch(dispatch_envelope(source)) + .read_plugins(read_request()) .await - .expect("mismatched trust snapshot should keep dispatch readable"); + .expect("invalid plugin remains diagnosable"); - assert!(response.effects.is_empty()); + assert_eq!( + response.sources[0].version.as_deref(), + Some(expected_version.as_str()) + ); + assert_eq!(response.sources[0].content_hash, expected_hash); assert_eq!( response.plugin_statuses[0].status, - PluginStatusKind::TrustRequired + PluginStatusKind::InvalidConfig ); - assert!(response - .diagnostics - .iter() - .any(|diagnostic| diagnostic.code == "opencode.trust_required")); } #[tokio::test] -async fn last_source_trust_ref_wins_for_duplicate_identity() { - let project = TempProject::new("latest-trust-fact"); - project.write_opencode_fixture(); - - let discovery_adapter = - load_opencode_workspace_adapter(project.path(), 1_720_000_001, epochs().trust_epoch, &[]) - .expect("load OpenCode-compatible workspace adapter"); - let discovery_host = PluginRuntimeHost::new(discovery_adapter); - let discovery = discovery_host - .read_plugins(read_request(vec![ - "opencode.local.workspace_tools".to_string() - ])) +async fn invalid_config_diagnostics_are_isolated_by_managed_source() { + let first = ManagedPackageFixture::new("invalid-config-a", PLUGIN_SOURCE); + let second = ManagedPackageFixture::new("invalid-config-b", PLUGIN_SOURCE); + first.add_opencode_config(r#"{"$schema":"https://invalid.example/config.json"}"#); + second.add_opencode_config(r#"{"$schema":"https://invalid.example/config.json"}"#); + + let mut ids = Vec::new(); + let mut diagnostic_ids = Vec::new(); + for fixture in [&first, &second] { + let host = PluginRuntimeHost::new( + load_opencode_package_adapter(fixture.approved_input().await, 1_720_000_001) + .expect("create adapter"), + ); + let response = host + .read_plugins(read_request()) + .await + .expect("read invalid config"); + let diagnostic = response + .diagnostics + .iter() + .find(|diagnostic| diagnostic.code == "opencode.config_invalid") + .expect("config diagnostic"); + ids.push(diagnostic.source.plugin_id.clone()); + diagnostic_ids.push(diagnostic.diagnostic_id.clone()); + } + + assert_ne!(ids[0], ids[1]); + assert_ne!(diagnostic_ids[0], diagnostic_ids[1]); +} + +#[tokio::test] +async fn host_source_identity_distinguishes_managed_package_origins() { + let first = ManagedPackageFixture::new("origin-a", PLUGIN_SOURCE); + let second = ManagedPackageFixture::new("origin-b", PLUGIN_SOURCE); + let first_host = PluginRuntimeHost::new( + load_opencode_package_adapter(first.approved_input().await, 1_720_000_001) + .expect("create first adapter"), + ); + let second_host = PluginRuntimeHost::new( + load_opencode_package_adapter(second.approved_input().await, 1_720_000_001) + .expect("create second adapter"), + ); + + let first_source = first_host + .read_plugins(read_request()) .await - .expect("read local plugin source"); - let discovered_source = discovery + .expect("read first source") .sources - .into_iter() - .find(|source| source.plugin_id == "opencode.local.workspace_tools") - .expect("local plugin source"); - let old_trusted_ref = trust_ref_for_source(&discovered_source, PluginTrustLevel::Trusted); - let new_revoked_ref = trust_ref_for_source(&discovered_source, PluginTrustLevel::Revoked); - - let source_trust_refs = vec![old_trusted_ref, new_revoked_ref]; - let adapter = load_opencode_workspace_adapter( - project.path(), - 1_720_000_001, - epochs().trust_epoch, - &source_trust_refs, - ) - .expect("load OpenCode-compatible workspace adapter"); - let host = PluginRuntimeHost::new(adapter); - let read_response = host - .read_plugins(read_request(vec![ - "opencode.local.workspace_tools".to_string() - ])) + .remove(0); + let second_source = second_host + .read_plugins(read_request()) .await - .expect("read source after duplicate trust snapshots"); - let source = read_response + .expect("read second source") .sources - .into_iter() - .find(|source| source.plugin_id == "opencode.local.workspace_tools") - .expect("local plugin source"); + .remove(0); - assert_eq!(source.trust_level, PluginTrustLevel::Revoked); - assert!(read_response.diagnostics.iter().any(|diagnostic| { - diagnostic.code == "opencode.trust_ref_conflict" - && diagnostic.source.plugin_id == "opencode.local.workspace_tools" - })); - - let response = host - .dispatch(dispatch_envelope(source)) - .await - .expect("last revoked trust snapshot should keep dispatch readable"); - - assert!(response.effects.is_empty()); - assert_eq!( - response.plugin_statuses[0].status, - PluginStatusKind::Disabled - ); + assert_ne!(first_source.source, second_source.source); + assert_ne!(first_source.plugin_id, second_source.plugin_id); + assert_eq!(first_source.content_hash, second_source.content_hash); } #[tokio::test] -async fn executable_plugin_source_is_not_evaluated_during_projection() { - let project = TempProject::new("non-executed-source"); - project.write_opencode_config(); - let marker = project.path().join("executed-marker.txt"); - let marker_path = marker.to_string_lossy().replace('\\', "\\\\"); - project.write_plugin( - "executable-plugin.ts", - &format!( - r#" -import {{ writeFileSync }} from "node:fs" -writeFileSync("{marker_path}", "executed") -export const ExecutablePlugin = async () => ({{ - tool: {{ - sideEffectTool: tool({{ - description: "Should only be discovered as a declaration", - args: {{}}, - async execute() {{ - writeFileSync("{marker_path}", "executed from tool") - }}, - }}), - }}, -}}) -"# - ), +async fn managed_source_uri_encodes_reserved_path_characters() { + let fixture = ManagedPackageFixture::new_with_path( + "reserved-uri", + PLUGIN_SOURCE, + ".opencode/plugins/nested #dir/workspace-tools.ts", + ); + let host = PluginRuntimeHost::new( + load_opencode_package_adapter(fixture.approved_input().await, 1_720_000_001) + .expect("create adapter"), ); - let adapter = - load_opencode_workspace_adapter(project.path(), 1_720_000_001, epochs().trust_epoch, &[]) - .expect("load OpenCode-compatible workspace adapter"); - let host = PluginRuntimeHost::new(adapter); - let response = host - .read_plugins(read_request(vec![ - "opencode.local.executable_plugin".to_string() - ])) + let source = host + .read_plugins(read_request()) .await - .expect("read executable-looking plugin source"); - - assert!(!marker.exists()); - assert!(response + .expect("read source") .sources - .iter() - .any(|source| source.plugin_id == "opencode.local.executable_plugin")); - assert!(response - .diagnostics - .iter() - .any(|diagnostic| diagnostic.code == "opencode.trust_required")); + .remove(0) + .source; + + assert!(source.starts_with("bitfun://managed-plugins/")); + assert!(source.contains("nested%20%23dir")); + assert!(!source.starts_with("file://")); } #[tokio::test] -async fn source_trust_epoch_mismatch_does_not_enable_candidate() { - let project = TempProject::new("trust-fact-epoch-mismatch"); - project.write_opencode_fixture(); - - let discovery_adapter = - load_opencode_workspace_adapter(project.path(), 1_720_000_001, epochs().trust_epoch, &[]) - .expect("load OpenCode-compatible workspace adapter"); - let discovery_host = PluginRuntimeHost::new(discovery_adapter); - let discovery = discovery_host - .read_plugins(read_request(vec![ - "opencode.local.workspace_tools".to_string() - ])) +async fn npm_projection_identity_distinguishes_managed_package_origins() { + let config = r#"{"$schema":"https://opencode.ai/config.json","plugin":["same-plugin"]}"#; + let first = ManagedPackageFixture::new("npm-origin-a", PLUGIN_SOURCE); + let second = ManagedPackageFixture::new("npm-origin-b", PLUGIN_SOURCE); + first.add_opencode_config(config); + second.add_opencode_config(config); + let first_host = PluginRuntimeHost::new( + load_opencode_package_adapter(first.approved_input().await, 1_720_000_001) + .expect("create first adapter"), + ); + let second_host = PluginRuntimeHost::new( + load_opencode_package_adapter(second.approved_input().await, 1_720_000_001) + .expect("create second adapter"), + ); + + let first_source = first_host + .read_plugins(read_request()) .await - .expect("read local plugin source"); - let discovered_source = discovery + .expect("read first source") .sources .into_iter() - .find(|source| source.plugin_id == "opencode.local.workspace_tools") - .expect("local plugin source"); - let stale_trust_ref = trust_ref_for_source(&discovered_source, PluginTrustLevel::Trusted); - - let source_trust_refs = vec![stale_trust_ref]; - let adapter = load_opencode_workspace_adapter( - project.path(), - 1_720_000_001, - epochs().trust_epoch + 1, - &source_trust_refs, - ) - .expect("load OpenCode-compatible workspace adapter"); - let host = PluginRuntimeHost::new(adapter); - let read_response = host - .read_plugins(read_request(vec![ - "opencode.local.workspace_tools".to_string() - ])) + .find(|source| source.plugin_id.starts_with("opencode.npm.same_plugin.")) + .expect("first npm projection"); + let second_source = second_host + .read_plugins(read_request()) .await - .expect("read source after stale trust epoch"); - let source = read_response + .expect("read second source") .sources .into_iter() - .find(|source| source.plugin_id == "opencode.local.workspace_tools") - .expect("local plugin source"); - - assert_eq!(source.trust_level, PluginTrustLevel::Unknown); - assert!(read_response - .diagnostics - .iter() - .any(|diagnostic| diagnostic.code == "opencode.trust_epoch_mismatch")); - - let response = host - .dispatch(dispatch_envelope(source)) - .await - .expect("stale trust epoch should keep dispatch readable"); - - assert!(response.effects.is_empty()); - assert_eq!( - response.plugin_statuses[0].status, - PluginStatusKind::TrustRequired + .find(|source| source.plugin_id.starts_with("opencode.npm.same_plugin.")) + .expect("second npm projection"); + + assert_ne!(first_source.source, second_source.source); + assert_ne!(first_source.plugin_id, second_source.plugin_id); + assert_eq!(first_source.content_hash, second_source.content_hash); + assert_eq!(first_source.version.as_deref(), Some("1.0.0")); + let digest = first_source + .plugin_id + .rsplit('.') + .next() + .expect("plugin id digest"); + assert_eq!(digest.len(), 32); + assert!(digest.bytes().all(|byte| byte.is_ascii_hexdigit())); + + let repeated_host = PluginRuntimeHost::new( + load_opencode_package_adapter(first.approved_input().await, 1_720_000_001) + .expect("create repeated adapter"), ); - assert!(response - .diagnostics - .iter() - .any(|diagnostic| diagnostic.code == "opencode.trust_epoch_mismatch")); + let repeated_source = repeated_host + .read_plugins(read_request()) + .await + .expect("read repeated source") + .sources + .into_iter() + .find(|source| source.plugin_id.starts_with("opencode.npm.same_plugin.")) + .expect("repeated npm projection"); + assert_eq!(first_source.plugin_id, repeated_source.plugin_id); } #[tokio::test] -async fn invalid_local_plugin_reports_diagnostic_without_hiding_valid_sources() { - let project = TempProject::new("invalid-local"); - project.write_opencode_fixture(); - project.write_plugin( - "broken-plugin.ts", - "export const BrokenPlugin = async () => ({ name: 'broken' })", +async fn managed_local_plugin_ids_distinguish_nested_and_dotted_paths() { + let fixture = ManagedPackageFixture::new_with_path( + "unique-local-ids", + PLUGIN_SOURCE, + ".opencode/plugins/a/foo.ts", + ); + fixture.add_declared_file(".opencode/plugins/b/foo.ts", PLUGIN_SOURCE.as_bytes()); + fixture.add_declared_file(".opencode/plugins/foo.test.ts", PLUGIN_SOURCE.as_bytes()); + let host = PluginRuntimeHost::new( + load_opencode_package_adapter(fixture.approved_input().await, 1_720_000_001) + .expect("create adapter"), ); - let adapter = - load_opencode_workspace_adapter(project.path(), 1_720_000_001, epochs().trust_epoch, &[]) - .expect("load OpenCode-compatible workspace adapter"); - let host = PluginRuntimeHost::new(adapter); let response = host - .read_plugins(read_request(Vec::new())) + .read_plugins(read_request()) .await - .expect("read plugins through host"); - - let source_ids = response + .expect("read local projections"); + let ids = response .sources .iter() .map(|source| source.plugin_id.as_str()) - .collect::>(); - assert!(source_ids.contains(&"opencode.local.workspace_tools")); - assert!(source_ids.contains(&"opencode.npm.opencode_wakatime")); - assert!(source_ids.contains(&"opencode.local.broken_plugin")); + .collect::>(); - let invalid_status = response - .plugin_statuses - .iter() - .find(|status| status.source.plugin_id == "opencode.local.broken_plugin") - .expect("invalid plugin status"); - assert_eq!(invalid_status.status, PluginStatusKind::InvalidConfig); - assert!(invalid_status - .config_validation - .as_ref() - .expect("config validation") - .issues - .iter() - .any(|issue| issue.code == "opencode.local_plugin_invalid")); - assert!(response.diagnostics.iter().any(|diagnostic| { - diagnostic.source.plugin_id == "opencode.local.broken_plugin" - && diagnostic.code == "opencode.local_plugin_invalid" - })); + assert_eq!(response.sources.len(), 3); + assert_eq!(ids.len(), 3); } #[tokio::test] -async fn event_only_local_plugin_projects_unsupported_hook_diagnostic() { - let project = TempProject::new("event-only"); - project.write_opencode_config(); - project.write_plugin( - "event-plugin.ts", - r#" -export const EventPlugin = async () => ({ - event: async ({ event }) => { - if (event.type === "session.idle") { - console.log(event.sessionID) - } - }, -}) -"#, +async fn npm_projection_ids_distinguish_collisions_and_deduplicate_exact_entries() { + let fixture = ManagedPackageFixture::new("unique-npm-ids", PLUGIN_SOURCE); + let long_name = "a".repeat(512); + let config = serde_json::json!({ + "$schema": "https://opencode.ai/config.json", + "plugin": ["foo-bar", "foo_bar", "foo-bar", long_name] + }); + fixture.add_opencode_config(&serde_json::to_string(&config).expect("serialize config")); + let host = PluginRuntimeHost::new( + load_opencode_package_adapter(fixture.approved_input().await, 1_720_000_001) + .expect("create adapter"), ); - let adapter = - load_opencode_workspace_adapter(project.path(), 1_720_000_001, epochs().trust_epoch, &[]) - .expect("load OpenCode-compatible workspace adapter"); - let host = PluginRuntimeHost::new(adapter); let response = host - .read_plugins(read_request(Vec::new())) + .read_plugins(read_request()) .await - .expect("read plugins through host"); - - let status = response - .plugin_statuses + .expect("read npm projections"); + let npm_ids = response + .sources .iter() - .find(|status| status.source.plugin_id == "opencode.local.event_plugin") - .expect("event plugin status"); - assert_eq!(status.status, PluginStatusKind::TrustRequired); - assert!(response.diagnostics.iter().any(|diagnostic| { - diagnostic.source.plugin_id == "opencode.local.event_plugin" - && diagnostic.code == "opencode.hook_projection_only" - && diagnostic.message.contains("event") - })); + .filter(|source| source.plugin_id.starts_with("opencode.npm.")) + .map(|source| source.plugin_id.as_str()) + .collect::>(); + + assert_eq!(npm_ids.len(), 3); + assert!(npm_ids.iter().all(|plugin_id| plugin_id.len() <= 96)); } #[tokio::test] -async fn oversized_local_plugin_reports_diagnostic_without_reading_source() { - let project = TempProject::new("large-local"); - project.write_opencode_config(); - let oversized_source = "x".repeat(1_048_577); - project.write_plugin("large-plugin.ts", &oversized_source); - - let adapter = - load_opencode_workspace_adapter(project.path(), 1_720_000_001, epochs().trust_epoch, &[]) - .expect("load OpenCode-compatible workspace adapter"); - let host = PluginRuntimeHost::new(adapter); +async fn package_without_recognized_opencode_entries_reports_diagnostic() { + let fixture = ManagedPackageFixture::new_with_path("unsupported-layout", "notes", "README.md"); + let host = PluginRuntimeHost::new( + load_opencode_package_adapter(fixture.approved_input().await, 1_720_000_001) + .expect("create adapter"), + ); + let response = host - .read_plugins(read_request(Vec::new())) + .read_plugins(read_request()) .await - .expect("read plugins through host"); + .expect("read unsupported package layout"); - let status = response - .plugin_statuses - .iter() - .find(|status| status.source.plugin_id == "opencode.local.large_plugin") - .expect("large plugin status"); - assert_eq!(status.status, PluginStatusKind::InvalidConfig); - assert!(response.diagnostics.iter().any(|diagnostic| { - diagnostic.source.plugin_id == "opencode.local.large_plugin" - && diagnostic.code == "opencode.local_plugin_too_large" - })); + assert_eq!(response.sources.len(), 1); assert!(response - .sources + .diagnostics .iter() - .any(|source| source.plugin_id == "opencode.npm.opencode_wakatime")); + .any(|diagnostic| { diagnostic.code == "opencode.package_no_supported_entry" })); } -fn read_request(plugin_ids: Vec) -> PluginRuntimeReadRequest { +fn read_request() -> PluginRuntimeReadRequest { PluginRuntimeReadRequest { - request_id: "read-1".to_string(), + request_id: "read-managed-package".to_string(), project_domain_id: "project-1".to_string(), workspace_id: "workspace-1".to_string(), - plugin_ids, + plugin_ids: Vec::new(), include_config_validation: true, epochs: epochs(), } @@ -613,13 +557,13 @@ fn read_request(plugin_ids: Vec) -> PluginRuntimeReadRequest { fn dispatch_envelope(source: bitfun_runtime_ports::PluginSourceRef) -> PluginDispatchEnvelope { PluginDispatchEnvelope { envelope_version: 1, - event_id: "event-tool".to_string(), - event_type: "agent.turn.completed".to_string(), - event_version: "2026-07-07".to_string(), + event_id: "event-1".to_string(), project_domain_id: "project-1".to_string(), workspace_id: "workspace-1".to_string(), - extension_point_id: "tool".to_string(), source, + event_type: "agent.turn.completed".to_string(), + event_version: "2026-07-07".to_string(), + extension_point_id: "tool".to_string(), declared_capability: PluginCapabilityRef { capability_id: "opencode.custom_tool".to_string(), owner: PluginOwnerRef { @@ -627,11 +571,10 @@ fn dispatch_envelope(source: bitfun_runtime_ports::PluginSourceRef) -> PluginDis id: "opencode.custom-tools".to_string(), }, }, - correlation_id: "corr-1".to_string(), - causation_id: None, - idempotency_key: "event-tool:tool".to_string(), deadline_ms: 30_000, - epochs: epochs(), + idempotency_key: "managed-package-event-1".to_string(), + correlation_id: "correlation-1".to_string(), + causation_id: None, payload_ref: Some(PluginPayloadRef { payload_id: "payload-1".to_string(), schema_version: "agent.turn.completed.v1".to_string(), @@ -639,16 +582,7 @@ fn dispatch_envelope(source: bitfun_runtime_ports::PluginSourceRef) -> PluginDis redaction: PluginPayloadRedaction::Partial, uri: Some("bitfun://payloads/payload-1".to_string()), }), - } -} - -fn trust_ref_for_source( - source: &PluginSourceRef, - trust_level: PluginTrustLevel, -) -> PluginSourceRef { - PluginSourceRef { - trust_level, - ..source.clone() + epochs: epochs(), } } diff --git a/src/crates/contracts/product-domains/AGENTS.md b/src/crates/contracts/product-domains/AGENTS.md index 94a9e4ae31..9d32c8c660 100644 --- a/src/crates/contracts/product-domains/AGENTS.md +++ b/src/crates/contracts/product-domains/AGENTS.md @@ -33,7 +33,8 @@ policies, and narrow ports; concrete runtime behavior belongs outside this crate - `function-agents` may own function-agent DTOs, prompt/domain policies, response parsing and repair rules, file-shape analysis, and Git/AI port traits. - `plugin-source` may own BitFun package manifest shapes, source identity, - workspace trust records, and pure trust epoch transitions. + fixed package input data, workspace trust records, and pure trust epoch + transitions. - 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/Cargo.toml b/src/crates/contracts/product-domains/Cargo.toml index a876f3f090..122c45e3d5 100644 --- a/src/crates/contracts/product-domains/Cargo.toml +++ b/src/crates/contracts/product-domains/Cargo.toml @@ -20,11 +20,12 @@ serde_json = { workspace = true } dirs = { workspace = true, optional = true } log = { workspace = true, optional = true } sha2 = { workspace = true, optional = true } +hex = { workspace = true, optional = true } which = { workspace = true, optional = true } [features] default = [] -plugin-source = [] +plugin-source = ["hex", "sha2"] miniapp = ["dirs", "sha2", "which"] function-agents = ["log"] product-full = ["plugin-source", "miniapp", "function-agents"] diff --git a/src/crates/contracts/product-domains/src/plugin_source.rs b/src/crates/contracts/product-domains/src/plugin_source.rs index ca5883ca26..f52e063699 100644 --- a/src/crates/contracts/product-domains/src/plugin_source.rs +++ b/src/crates/contracts/product-domains/src/plugin_source.rs @@ -5,7 +5,8 @@ //! persistence are concrete service integration responsibilities. use serde::{Deserialize, Serialize}; -use std::collections::HashSet; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet, HashSet}; use std::fmt; const PLUGIN_PACKAGE_MANIFEST_SCHEMA_VERSION: u16 = 1; @@ -18,6 +19,8 @@ const MAX_PACKAGE_PATH_LEN: usize = 1024; const MAX_SOURCE_PATH_LEN: usize = 256; const MAX_SCOPE_ID_LEN: usize = 256; const MAX_PACKAGE_FILES: usize = 64; +const MAX_PACKAGE_FILE_BYTES: usize = 1024 * 1024; +const MAX_PACKAGE_BYTES: usize = 16 * 1024 * 1024; const MAX_TRUST_RECORDS: usize = 1024; const SHA256_PREFIX: &str = "sha256:"; @@ -46,7 +49,7 @@ impl PluginPackageManifest { Ok(manifest) } - fn validate(&self) -> Result<(), PluginSourceContractError> { + pub fn validate(&self) -> Result<(), PluginSourceContractError> { if self.schema_version != PLUGIN_PACKAGE_MANIFEST_SCHEMA_VERSION { return Err(PluginSourceContractError::UnsupportedManifestSchema( self.schema_version, @@ -74,6 +77,27 @@ impl PluginPackageManifest { Ok(()) } + + pub fn content_hash(&self) -> Result { + self.validate()?; + let mut files = self.files.iter().collect::>(); + files.sort_by(|left, right| left.path.cmp(&right.path)); + let mut hasher = Sha256::new(); + hasher.update(self.schema_version.to_le_bytes()); + hasher.update([0]); + hasher.update(self.id.as_bytes()); + hasher.update([0]); + hasher.update(self.version.as_bytes()); + hasher.update([0]); + hasher.update(self.adapter.as_bytes()); + for file in files { + hasher.update([0]); + hasher.update(file.path.as_bytes()); + hasher.update([0]); + hasher.update(file.sha256.as_bytes()); + } + Ok(format!("sha256:{}", hex::encode(hasher.finalize()))) + } } #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -86,6 +110,77 @@ pub struct PluginPackageSourceIdentity { pub content_hash: String, } +/// Fixed package content passed from managed source IO to an ecosystem adapter. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginPackageInput { + manifest: PluginPackageManifest, + source: PluginPackageSourceIdentity, + files: BTreeMap>, +} + +impl PluginPackageInput { + pub fn new( + manifest: PluginPackageManifest, + source: PluginPackageSourceIdentity, + files: BTreeMap>, + ) -> Result { + manifest.validate()?; + source.validate()?; + if source.package_id != manifest.id + || source.version != manifest.version + || source.adapter != manifest.adapter + || source.content_hash != manifest.content_hash()? + { + return Err(PluginSourceContractError::PackageIdentityMismatch); + } + let declared_paths = manifest + .files + .iter() + .map(|file| file.path.as_str()) + .collect::>(); + let provided_paths = files.keys().map(String::as_str).collect::>(); + if declared_paths != provided_paths { + return Err(PluginSourceContractError::PackageFileSetMismatch); + } + let mut package_bytes = 0_usize; + for file in &manifest.files { + let bytes = &files[&file.path]; + if bytes.len() > MAX_PACKAGE_FILE_BYTES { + return Err(PluginSourceContractError::PackageFileTooLarge( + file.path.clone(), + )); + } + package_bytes = package_bytes + .checked_add(bytes.len()) + .ok_or(PluginSourceContractError::PackageContentTooLarge)?; + if package_bytes > MAX_PACKAGE_BYTES { + return Err(PluginSourceContractError::PackageContentTooLarge); + } + let actual_hash = format!("sha256:{}", hex::encode(Sha256::digest(bytes))); + if actual_hash != file.sha256 { + return Err(PluginSourceContractError::PackageFileHashMismatch( + file.path.clone(), + )); + } + } + Ok(Self { + manifest, + source, + files, + }) + } + + pub fn into_parts( + self, + ) -> ( + PluginPackageManifest, + PluginPackageSourceIdentity, + BTreeMap>, + ) { + (self.manifest, self.source, self.files) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] #[non_exhaustive] @@ -317,6 +412,11 @@ pub enum PluginSourceContractError { InvalidPackagePath(String), InvalidSha256(String), DuplicatePackageFile(String), + PackageIdentityMismatch, + PackageFileSetMismatch, + PackageFileTooLarge(String), + PackageContentTooLarge, + PackageFileHashMismatch(String), InvalidSourcePath, EmptyScope, InvalidScope, @@ -355,6 +455,30 @@ impl fmt::Display for PluginSourceContractError { Self::DuplicatePackageFile(path) => { write!(formatter, "duplicate plugin package file: {path}") } + Self::PackageIdentityMismatch => { + write!( + formatter, + "plugin package input identity does not match its manifest" + ) + } + Self::PackageFileSetMismatch => { + write!( + formatter, + "plugin package input files do not match its manifest" + ) + } + Self::PackageFileTooLarge(path) => { + write!(formatter, "plugin package input file is too large: {path}") + } + Self::PackageContentTooLarge => { + write!(formatter, "plugin package input content is too large") + } + Self::PackageFileHashMismatch(path) => { + write!( + formatter, + "plugin package input file hash does not match: {path}" + ) + } Self::InvalidSourcePath => write!(formatter, "invalid plugin package source path"), Self::EmptyScope => write!(formatter, "plugin trust scope is empty"), Self::InvalidScope => write!(formatter, "invalid plugin trust scope"), diff --git a/src/crates/contracts/product-domains/tests/plugin_source_contracts.rs b/src/crates/contracts/product-domains/tests/plugin_source_contracts.rs index e5ad56d3bf..fb3e4cbf08 100644 --- a/src/crates/contracts/product-domains/tests/plugin_source_contracts.rs +++ b/src/crates/contracts/product-domains/tests/plugin_source_contracts.rs @@ -1,7 +1,9 @@ use bitfun_product_domains::plugin_source::{ - PluginPackageManifest, PluginPackageSourceIdentity, PluginPackageTrustLevel, - PluginTrustDecision, PluginTrustStore, + PluginPackageInput, PluginPackageManifest, PluginPackageSourceIdentity, + PluginPackageTrustLevel, PluginTrustDecision, PluginTrustStore, }; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; const HASH_A: &str = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; const HASH_B: &str = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; @@ -53,6 +55,31 @@ fn manifest_v1_accepts_only_normalized_declared_package_files() { } } +#[test] +fn fixed_package_input_enforces_file_set_hash_and_size_limits() { + let bytes = b"export const Demo = async () => ({})".to_vec(); + let file_hash = format!("sha256:{}", hex::encode(Sha256::digest(&bytes))); + let manifest = PluginPackageManifest::parse_json(&format!( + r#"{{"schemaVersion":1,"id":"acme.demo","version":"1.0.0","adapter":"test_adapter","files":[{{"path":"plugin/main.ts","sha256":"{file_hash}"}}]}}"# + )) + .expect("valid manifest"); + let identity = source( + &manifest.content_hash().expect("manifest hash"), + "native:workspace-source", + ); + let files = BTreeMap::from([("plugin/main.ts".to_string(), bytes)]); + + PluginPackageInput::new(manifest.clone(), identity.clone(), files.clone()) + .expect("valid fixed input"); + + let mut extra = files.clone(); + extra.insert("plugin/extra.ts".to_string(), Vec::new()); + assert!(PluginPackageInput::new(manifest.clone(), identity.clone(), extra).is_err()); + + let oversized = BTreeMap::from([("plugin/main.ts".to_string(), vec![0; 1024 * 1024 + 1])]); + assert!(PluginPackageInput::new(manifest, identity, oversized).is_err()); +} + #[test] fn manifest_and_trust_identity_reject_terminal_spoofing_characters() { let manifest = format!( diff --git a/src/crates/services/services-integrations/AGENTS.md b/src/crates/services/services-integrations/AGENTS.md index 519bfb1361..f897b2a8b9 100644 --- a/src/crates/services/services-integrations/AGENTS.md +++ b/src/crates/services/services-integrations/AGENTS.md @@ -60,8 +60,9 @@ slices that are outside pure product logic but still platform-neutral. lifecycle. Manager workflow orchestration remains outside this crate until reviewed owner migration. - Managed plugin source integration may own bounded package discovery, - integrity checks, no-follow path handling, trust-file locking, and atomic - persistence. Product path selection stays in assembly; ecosystem parsing and + integrity checks, fixed package input reads, no-follow path handling, + trust-file locking, and atomic persistence. Product path selection stays in + assembly; ecosystem parsing and Plugin Runtime Host behavior stay in their adapter and execution owners. - Announcement remote fetch/cache lives here; product assembly supplies config values such as endpoint, locale, version, platform, and cache path. diff --git a/src/crates/services/services-integrations/src/plugin_source.rs b/src/crates/services/services-integrations/src/plugin_source.rs index dfaa52c490..ce7e6f4eb2 100644 --- a/src/crates/services/services-integrations/src/plugin_source.rs +++ b/src/crates/services/services-integrations/src/plugin_source.rs @@ -4,8 +4,8 @@ //! file interpretation remains in the corresponding adapter. use bitfun_product_domains::plugin_source::{ - PluginPackageManifest, PluginPackageSourceIdentity, PluginPackageTrustLevel, - PluginSourceContractError, PluginTrustStore, + PluginPackageInput, PluginPackageManifest, PluginPackageSourceIdentity, + PluginPackageTrustLevel, PluginSourceContractError, PluginTrustStore, }; pub use bitfun_product_domains::plugin_source::{ PluginPackageTrustLevel as ManagedPluginTrustLevel, @@ -13,7 +13,7 @@ pub use bitfun_product_domains::plugin_source::{ }; use fs2::FileExt; use sha2::{Digest, Sha256}; -use std::collections::HashSet; +use std::collections::{BTreeMap, HashSet}; use std::ffi::OsStr; use std::fs::OpenOptions; use std::io::{self, ErrorKind, Read, Write}; @@ -68,6 +68,11 @@ pub enum ManagedPluginSourceError { package_id: String, diagnostic: String, }, + #[error("managed plugin package {package_id} is temporarily unavailable: {diagnostic}")] + TemporarilyUnavailable { + package_id: String, + diagnostic: String, + }, #[error("managed plugin trust update failed: {0}")] TrustStore(String), } @@ -90,8 +95,27 @@ impl From for ManagedPluginSourceError { } } +fn map_load_store_error( + package_id: &str, + error: PluginSourceStoreError, +) -> ManagedPluginSourceError { + if matches!( + error, + PluginSourceStoreError::TrustGenerationChanged | PluginSourceStoreError::SourceChanged + ) || trust_store_issue_code(&error) == "trust_store_unavailable" + { + ManagedPluginSourceError::TemporarilyUnavailable { + package_id: package_id.to_string(), + diagnostic: format!("{error}; retry the operation"), + } + } else { + error.into() + } +} + pub struct ManagedPluginSourceService { store: ProductPluginSourceStore, + load_gate: tokio::sync::Semaphore, } impl ManagedPluginSourceService { @@ -112,6 +136,7 @@ impl ManagedPluginSourceService { ], trust_path, ), + load_gate: tokio::sync::Semaphore::new(1), } } @@ -161,6 +186,128 @@ impl ManagedPluginSourceService { }; Ok(build_snapshot(discovery, Some(trust_store), None, &scope)) } + + /// Load one selected package as fixed content for an ecosystem adapter. + pub async fn load_package( + &self, + workspace: &Path, + package_id: &str, + ) -> Result { + let mut budget = OperationScanBudget::new(); + let _load_permit = tokio::time::timeout(budget.remaining_time(), self.load_gate.acquire()) + .await + .map_err(|_| ManagedPluginSourceError::TemporarilyUnavailable { + package_id: package_id.to_string(), + diagnostic: "load capacity is busy; retry the operation".to_string(), + })? + .map_err(|_| ManagedPluginSourceError::TemporarilyUnavailable { + package_id: package_id.to_string(), + diagnostic: "load capacity is unavailable; retry the operation".to_string(), + })?; + let scope = workspace_scope(workspace); + let mut discovery = self + .store + .discover_with_budget_for(&mut budget, Some(package_id)) + .await; + if !discovery.is_complete() { + let issue = discovery + .issues + .iter() + .find(|issue| issue.package_id.as_deref() == Some(package_id)) + .or_else(|| discovery.issues.iter().find(|issue| issue.code.is_error())); + let diagnostic = issue.map_or_else( + || "managed plugin discovery is incomplete".to_string(), + |issue| format!("{}: {}", issue.code.as_str(), issue.message), + ); + return Err( + if issue.is_some_and(|issue| { + matches!( + issue.code, + PluginSourceIssueCode::RootReadFailed + | PluginSourceIssueCode::ScanBudgetExceeded + | PluginSourceIssueCode::FileReadFailed + ) + }) { + ManagedPluginSourceError::TemporarilyUnavailable { + package_id: package_id.to_string(), + diagnostic, + } + } else { + ManagedPluginSourceError::PackageInvalid { + package_id: package_id.to_string(), + diagnostic, + } + }, + ); + } + let trust_store = self + .store + .load_trust_store_with_budget(&budget) + .await + .map_err(|error| map_load_store_error(package_id, error))?; + let index = discovery + .packages + .iter() + .position(|package| package.identity.package_id == package_id) + .ok_or_else(|| { + discovery + .issues + .iter() + .find(|issue| { + issue.package_id.as_deref() == Some(package_id) && issue.code.is_error() + }) + .map_or_else( + || ManagedPluginSourceError::PackageNotFound(package_id.to_string()), + |issue| ManagedPluginSourceError::PackageInvalid { + package_id: package_id.to_string(), + diagnostic: format!("{}: {}", issue.code.as_str(), issue.message), + }, + ) + })?; + let package = discovery.packages.swap_remove(index); + let source_trust_level = trust_store.trust_level_for( + &scope.project_domain_id, + &scope.workspace_id, + &package.identity, + ); + if source_trust_level != PluginPackageTrustLevel::SourceApproved { + return Err(ManagedPluginSourceError::PackageInvalid { + package_id: package_id.to_string(), + diagnostic: "managed plugin package source is not approved".to_string(), + }); + } + let input_source = package.identity.clone(); + let input = PluginPackageInput::new( + package.manifest, + package.identity, + package + .declared_files + .expect("selected package discovery must retain declared files"), + ) + .map_err(|error| ManagedPluginSourceError::PackageInvalid { + package_id: package_id.to_string(), + diagnostic: error.to_string(), + })?; + if !self + .store + .trust_approval_matches( + trust_store.epoch(), + &scope.project_domain_id, + &scope.workspace_id, + &input_source, + &budget, + ) + .await + .map_err(|error| map_load_store_error(package_id, error))? + { + return Err(ManagedPluginSourceError::TemporarilyUnavailable { + package_id: package_id.to_string(), + diagnostic: "managed plugin trust changed while loading; retry the operation" + .to_string(), + }); + } + Ok(input) + } } fn build_snapshot( @@ -551,6 +698,8 @@ impl PluginPackageScope { #[derive(Debug, Clone)] struct DiscoveredPluginPackage { identity: PluginPackageSourceIdentity, + manifest: PluginPackageManifest, + declared_files: Option>>, source_scope: PluginPackageScope, display_path: PathBuf, } @@ -840,6 +989,14 @@ impl ProductPluginSourceStore { async fn discover_with_budget( &self, budget: &mut OperationScanBudget, + ) -> PluginSourceDiscovery { + self.discover_with_budget_for(budget, None).await + } + + async fn discover_with_budget_for( + &self, + budget: &mut OperationScanBudget, + retained_package_id: Option<&str>, ) -> PluginSourceDiscovery { let mut discovery = PluginSourceDiscovery::default(); for root in &self.roots { @@ -848,9 +1005,12 @@ impl ProductPluginSourceStore { discovery.issues.push(operation_scan_timeout(&root.path)); break; } - if tokio::time::timeout(remaining, self.discover_root(root, &mut discovery, budget)) - .await - .is_err() + if tokio::time::timeout( + remaining, + self.discover_root(root, &mut discovery, budget, retained_package_id), + ) + .await + .is_err() { discovery.issues.push(operation_scan_timeout(&root.path)); break; @@ -868,6 +1028,7 @@ impl ProductPluginSourceStore { root: &PluginPackageRoot, discovery: &mut PluginSourceDiscovery, scan_budget: &mut OperationScanBudget, + retained_package_id: Option<&str>, ) { let root_metadata = match fs::symlink_metadata(&root.path).await { Ok(metadata) => metadata, @@ -1009,6 +1170,9 @@ impl ProductPluginSourceStore { .insert(package_id.to_string()); } } + if retained_package_id.is_some_and(|target| package_id != Some(target)) { + continue; + } let metadata = match fs::symlink_metadata(&package_path).await { Ok(metadata) => metadata, Err(error) => { @@ -1106,6 +1270,7 @@ impl ProductPluginSourceStore { root.source_scope, &secure_package, scan_budget, + package_id == retained_package_id, ) .await { @@ -1282,8 +1447,21 @@ impl ProductPluginSourceStore { Result, ) { let mut scan_budget = OperationScanBudget::new(); - let discovery = self.discover_with_budget(&mut scan_budget).await; - let mut file_guard = match self.acquire_trust_file_lock(&scan_budget).await { + self.reconcile_trust_with_budget(project_domain_id, workspace_id, &mut scan_budget) + .await + } + + async fn reconcile_trust_with_budget( + &self, + project_domain_id: &str, + workspace_id: &str, + scan_budget: &mut OperationScanBudget, + ) -> ( + PluginSourceDiscovery, + Result, + ) { + let discovery = self.discover_with_budget(scan_budget).await; + let mut file_guard = match self.acquire_trust_file_lock(scan_budget).await { Ok(guard) => guard, Err(error) => return (discovery, Err(error)), }; @@ -1331,7 +1509,7 @@ impl ProductPluginSourceStore { } return (discovery, Ok(store)); } - let verified = self.discover_with_budget(&mut scan_budget).await; + let verified = self.discover_with_budget(scan_budget).await; if !verified.is_complete() || discovery.identities() != verified.identities() || discovery.workspace_package_ids != verified.workspace_package_ids @@ -1361,6 +1539,29 @@ impl ProductPluginSourceStore { (verified, Ok(next)) } + async fn trust_approval_matches( + &self, + expected_epoch: u64, + project_domain_id: &str, + workspace_id: &str, + source: &PluginPackageSourceIdentity, + operation_budget: &OperationScanBudget, + ) -> Result { + let mut file_guard = self.acquire_trust_file_lock(operation_budget).await?; + let current = self.load_trust_store_locked(&mut file_guard).await?; + Ok(current.epoch() == expected_epoch + && current.trust_level_for(project_domain_id, workspace_id, source) + == PluginPackageTrustLevel::SourceApproved) + } + + async fn load_trust_store_with_budget( + &self, + operation_budget: &OperationScanBudget, + ) -> Result { + let mut file_guard = self.acquire_trust_file_lock(operation_budget).await?; + self.load_trust_store_locked(&mut file_guard).await + } + async fn acquire_trust_file_lock( &self, operation_budget: &OperationScanBudget, @@ -2124,6 +2325,7 @@ async fn discover_package( source_scope: PluginPackageScope, secure_package: &SecurePackageDirectory, scan_budget: &mut OperationScanBudget, + retain_declared_files: bool, ) -> Result { let manifest_path = package_path.join(PLUGIN_MANIFEST_FILE); let metadata = fs::symlink_metadata(&manifest_path) @@ -2229,19 +2431,19 @@ async fn discover_package( } let mut package_bytes = 0_u64; + let mut declared_files = BTreeMap::new(); for file in &manifest.files { + let bytes = validate_declared_file( + package_path, + &canonical_path, + secure_package, + &file.path, + &file.sha256, + scan_budget, + ) + .await?; package_bytes = package_bytes - .checked_add( - validate_declared_file( - package_path, - &canonical_path, - secure_package, - &file.path, - &file.sha256, - scan_budget, - ) - .await?, - ) + .checked_add(bytes.len() as u64) .ok_or_else(|| { PluginSourceIssue::new( PluginSourceIssueCode::FileTooLarge, @@ -2256,17 +2458,24 @@ async fn discover_package( "plugin package declared files exceed the 16 MiB limit", )); } + if retain_declared_files { + declared_files.insert(file.path.clone(), bytes); + } } let identity = PluginPackageSourceIdentity { package_id: manifest.id.clone(), version: manifest.version.clone(), adapter: manifest.adapter.clone(), source_path: native_path_identity(&canonical_path), - content_hash: package_content_hash(&manifest), + content_hash: manifest + .content_hash() + .expect("parsed package manifest must remain valid"), }; Ok(DiscoveredPluginPackage { identity, + manifest, + declared_files: retain_declared_files.then_some(declared_files), source_scope, display_path: canonical_path, }) @@ -2286,7 +2495,7 @@ async fn validate_declared_file( relative_path: &str, expected_hash: &str, scan_budget: &mut OperationScanBudget, -) -> Result { +) -> Result, PluginSourceIssue> { let relative_path = PathBuf::from(relative_path.replace('/', std::path::MAIN_SEPARATOR_STR)); validate_parent_components(package_path, &relative_path).await?; let path = package_path.join(&relative_path); @@ -2368,7 +2577,7 @@ async fn validate_declared_file( format!("declared hash {expected_hash} does not match {actual_hash}"), )); } - Ok(bytes.len() as u64) + Ok(bytes) } async fn validate_parent_components( @@ -2414,43 +2623,23 @@ fn declared_parent_metadata_issue_code(kind: ErrorKind) -> PluginSourceIssueCode } } -fn package_content_hash(manifest: &PluginPackageManifest) -> String { - let mut files = manifest.files.iter().collect::>(); - files.sort_by(|left, right| left.path.cmp(&right.path)); - let mut hasher = Sha256::new(); - hasher.update(manifest.schema_version.to_le_bytes()); - hasher.update([0]); - hasher.update(manifest.id.as_bytes()); - hasher.update([0]); - hasher.update(manifest.version.as_bytes()); - hasher.update([0]); - hasher.update(manifest.adapter.as_bytes()); - for file in files { - hasher.update([0]); - hasher.update(file.path.as_bytes()); - hasher.update([0]); - hasher.update(file.sha256.as_bytes()); - } - format!("sha256:{}", hex::encode(hasher.finalize())) -} - #[cfg(test)] mod tests { use super::{ build_snapshot, charge_scanned_read, declared_parent_metadata_issue_code, - native_path_identity, package_content_hash, persist_trust_bytes_with_parent_sync, + map_load_store_error, native_path_identity, persist_trust_bytes_with_parent_sync, read_bounded_reader, read_scanned_file, replace_file_atomically, trust_file_identity, - trust_store_issue_code, workspace_scope, ManagedPluginSourceError, OperationScanBudget, - PluginPackageManifest, PluginPackageRoot, PluginPackageScope, PluginSourceDiscovery, - PluginSourceIssue, PluginSourceIssueCode, PluginSourceStoreError, PluginTrustScope, - ProductPluginSourceStore, ScannedFileReadError, SecureManagedRoot, - MAX_OPERATION_READ_BYTES, MAX_PACKAGE_FILE_BYTES, MAX_TRUST_STORE_BYTES, + trust_store_issue_code, workspace_scope, ManagedPluginSourceError, + ManagedPluginSourceService, OperationScanBudget, PluginPackageManifest, PluginPackageRoot, + PluginPackageScope, PluginSourceDiscovery, PluginSourceIssue, PluginSourceIssueCode, + PluginSourceStoreError, PluginTrustScope, ProductPluginSourceStore, ScannedFileReadError, + SecureManagedRoot, MAX_OPERATION_READ_BYTES, MAX_PACKAGE_FILE_BYTES, MAX_TRUST_STORE_BYTES, }; use bitfun_product_domains::plugin_source::PluginPackageTrustLevel; use bitfun_product_domains::plugin_source::PluginTrustDecision; use sha2::{Digest, Sha256}; - use std::io::{self, Read}; - use std::path::Path; + use std::io::{self, ErrorKind, Read}; + use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; fn sha256(bytes: &[u8]) -> String { @@ -3157,19 +3346,217 @@ mod tests { assert_eq!(discovery.packages.len(), 1); assert!(discovery.issues.is_empty()); + assert!(discovery.packages[0].declared_files.is_none()); assert_eq!( discovery.packages[0].identity.content_hash, - package_content_hash( - &PluginPackageManifest::parse_json( - &tokio::fs::read_to_string(root.join("acme.demo/bitfun.plugin.json")) - .await - .expect("read manifest") - ) - .expect("parse manifest") + PluginPackageManifest::parse_json( + &tokio::fs::read_to_string(root.join("acme.demo/bitfun.plugin.json")) + .await + .expect("read manifest") ) + .expect("parse manifest") + .content_hash() + .expect("hash manifest") + ); + + let mut budget = OperationScanBudget::new(); + let retained = store + .discover_with_budget_for(&mut budget, Some("acme.demo")) + .await; + assert_eq!( + retained.packages[0] + .declared_files + .as_ref() + .expect("selected package content") + .keys() + .map(String::as_str) + .collect::>(), + ["plugin/demo.ts"] ); } + #[tokio::test] + async fn fixed_package_reads_are_serialized_per_service_instance() { + let temp = tempfile::tempdir().expect("tempdir"); + let workspace = temp.path().join("workspace"); + let workspace_root = workspace.join(".bitfun/plugins"); + let user_root = temp.path().join("user/plugins"); + tokio::fs::create_dir_all(&user_root) + .await + .expect("create user root"); + let source = b"export const Demo = async () => ({})"; + write_package(&workspace_root, "acme.demo", source, &sha256(source)).await; + let service = std::sync::Arc::new(ManagedPluginSourceService::new( + user_root, + temp.path().join("user"), + workspace_root, + workspace.clone(), + temp.path().join("trust.json"), + )); + service + .set_trust(&workspace, "acme.demo", PluginTrustDecision::ApproveSource) + .await + .expect("approve source"); + let permit = service + .load_gate + .acquire() + .await + .expect("acquire load gate"); + let pending_service = service.clone(); + let pending_workspace = workspace.clone(); + let pending = tokio::spawn(async move { + pending_service + .load_package(&pending_workspace, "acme.demo") + .await + }); + + tokio::time::sleep(Duration::from_millis(20)).await; + assert!(!pending.is_finished()); + drop(permit); + pending + .await + .expect("join fixed package read") + .expect("load after gate release"); + } + + #[tokio::test] + async fn unavailable_load_gate_is_reported_as_temporary() { + let temp = tempfile::tempdir().expect("tempdir"); + let workspace = temp.path().join("workspace"); + let service = ManagedPluginSourceService::new( + temp.path().join("user/plugins"), + temp.path().join("user"), + workspace.join(".bitfun/plugins"), + workspace.clone(), + temp.path().join("trust.json"), + ); + service.load_gate.close(); + + let error = service + .load_package(&workspace, "acme.demo") + .await + .expect_err("closed load gate must fail"); + + assert!(matches!( + error, + ManagedPluginSourceError::TemporarilyUnavailable { .. } + )); + } + + #[test] + fn retryable_store_failures_are_reported_as_temporary_load_errors() { + for error in [ + PluginSourceStoreError::TrustLockTimeout, + PluginSourceStoreError::TrustGenerationChanged, + PluginSourceStoreError::SourceChanged, + PluginSourceStoreError::TrustReadTask("cancelled".to_string()), + PluginSourceStoreError::TrustLockTask("cancelled".to_string()), + PluginSourceStoreError::TrustLockIo(io::Error::other("unavailable")), + PluginSourceStoreError::TrustRead { + path: PathBuf::from("trust.json"), + source: io::Error::other("unavailable"), + }, + ] { + assert!(matches!( + map_load_store_error("acme.demo", error), + ManagedPluginSourceError::TemporarilyUnavailable { .. } + )); + } + + let invalid = PluginSourceStoreError::TrustRead { + path: PathBuf::from("trust.json"), + source: io::Error::new(ErrorKind::InvalidData, "invalid"), + }; + assert!(matches!( + map_load_store_error("acme.demo", invalid), + ManagedPluginSourceError::TrustStore(_) + )); + } + + #[tokio::test] + async fn selected_package_load_ignores_unrelated_invalid_package_content() { + let temp = tempfile::tempdir().expect("tempdir"); + let workspace = temp.path().join("workspace"); + let workspace_root = workspace.join(".bitfun/plugins"); + let user_root = temp.path().join("user/plugins"); + tokio::fs::create_dir_all(&user_root) + .await + .expect("create user root"); + let source = b"export const Demo = async () => ({})"; + write_package(&workspace_root, "acme.demo", source, &sha256(source)).await; + let service = ManagedPluginSourceService::new( + user_root, + temp.path().join("user"), + workspace_root.clone(), + workspace.clone(), + temp.path().join("trust.json"), + ); + service + .set_trust(&workspace, "acme.demo", PluginTrustDecision::ApproveSource) + .await + .expect("approve source"); + tokio::fs::create_dir_all(workspace_root.join("broken.unrelated")) + .await + .expect("create unrelated package"); + tokio::fs::write( + workspace_root.join("broken.unrelated/bitfun.plugin.json"), + b"not json", + ) + .await + .expect("write invalid unrelated manifest"); + + service + .load_package(&workspace, "acme.demo") + .await + .expect("unrelated package must not block selected load"); + } + + #[tokio::test] + async fn final_trust_check_rejects_same_epoch_revocation() { + let temp = tempfile::tempdir().expect("tempdir"); + let root = temp.path().join("plugins"); + let trust_path = temp.path().join("trust.json"); + let source = b"export const Demo = async () => ({})"; + write_package(&root, "acme.demo", source, &sha256(source)).await; + let store = ProductPluginSourceStore::new( + vec![PluginPackageRoot::new(root, PluginPackageScope::Workspace)], + trust_path.clone(), + ); + let (discovery, approved) = store + .apply_trust_decision( + "project-1", + "workspace-1", + "acme.demo", + PluginTrustDecision::ApproveSource, + 1, + ) + .await + .expect("approve source"); + let approved_source = discovery.packages[0].identity.clone(); + let mut external: serde_json::Value = + serde_json::from_slice(&tokio::fs::read(&trust_path).await.expect("read trust file")) + .expect("parse trust file"); + external["records"][0]["trustLevel"] = serde_json::json!("revoked"); + tokio::fs::write( + &trust_path, + serde_json::to_vec_pretty(&external).expect("serialize external trust update"), + ) + .await + .expect("replace trust file with same epoch"); + let budget = OperationScanBudget::new(); + + assert!(!store + .trust_approval_matches( + approved.epoch(), + "project-1", + "workspace-1", + &approved_source, + &budget, + ) + .await + .expect("check trust approval")); + } + #[tokio::test] async fn bounded_reads_reject_oversized_package_and_trust_files() { let temp = tempfile::tempdir().expect("tempdir");