From d33f62b8a9f338f485d65a963ffd7f1df4cbf5da Mon Sep 17 00:00:00 2001 From: limityan Date: Thu, 23 Jul 2026 17:54:14 +0800 Subject: [PATCH] feat(extensions): add static external Hook catalog Add runtime-free OpenCode, Claude Code, and Codex discovery behind shared contracts and bounded parsers. Expose the read-only catalog through TUI and Desktop settings with explicit remote gating. --- Cargo.toml | 3 + .../external-ai-work-sources-design.md | 52 +- .../opencode-extension-compatibility.md | 34 +- docs/architecture/product-architecture.md | 5 +- .../core-boundaries/rules/crate-layout.mjs | 3 + scripts/core-boundaries/rules/crate-rules.mjs | 30 + .../rules/source/forbidden-rules.mjs | 40 + .../rules/source/public-api-rules.mjs | 137 +++- scripts/core-boundaries/self-test.mjs | 80 +- src/apps/cli/src/actions.rs | 16 + src/apps/cli/src/modes/chat.rs | 15 + src/apps/cli/src/modes/chat/commands.rs | 12 +- src/apps/cli/src/modes/chat/external_hooks.rs | 339 ++++++++ .../cli/src/modes/chat/external_review.rs | 6 +- src/apps/cli/src/modes/chat/run.rs | 3 + src/apps/cli/src/modes/chat/tests.rs | 270 +++++- src/apps/cli/src/ui/command_palette.rs | 1 + src/apps/cli/src/ui/startup.rs | 1 + .../desktop/src/api/external_hooks_api.rs | 55 ++ .../desktop/src/api/external_sources_api.rs | 2 +- src/apps/desktop/src/api/mod.rs | 1 + .../src/api/remote_workspace_policy.rs | 4 + src/apps/desktop/src/lib.rs | 2 + src/crates/adapters/AGENTS.md | 5 +- .../adapters/claude-code-adapter/AGENTS.md | 6 + .../adapters/claude-code-adapter/Cargo.toml | 24 + .../claude-code-adapter/src/hook_source.rs | 451 ++++++++++ .../adapters/claude-code-adapter/src/lib.rs | 5 + .../claude-code-adapter/tests/hook_source.rs | 229 ++++++ src/crates/adapters/codex-adapter/AGENTS.md | 6 + src/crates/adapters/codex-adapter/Cargo.toml | 25 + .../adapters/codex-adapter/src/hook_source.rs | 669 +++++++++++++++ src/crates/adapters/codex-adapter/src/lib.rs | 5 + .../codex-adapter/tests/hook_source.rs | 525 ++++++++++++ .../adapters/opencode-adapter/Cargo.toml | 1 + .../opencode-adapter/src/command_source.rs | 7 +- .../opencode-adapter/src/hook_source.rs | 771 ++++++++++++++++++ .../adapters/opencode-adapter/src/lib.rs | 2 + .../opencode-adapter/src/source_adapter.rs | 247 ++++++ .../opencode-adapter/tests/hook_source.rs | 760 +++++++++++++++++ .../adapters/static-hook-support/Cargo.toml | 23 + .../adapters/static-hook-support/src/lib.rs | 319 ++++++++ .../static-hook-support/tests/parser.rs | 187 +++++ src/crates/assembly/core/Cargo.toml | 4 + .../assembly/core/src/external_hooks.rs | 327 ++++++++ .../assembly/core/src/external_hooks_tests.rs | 160 ++++ .../assembly/core/src/external_sources.rs | 6 +- src/crates/assembly/core/src/lib.rs | 4 + .../assembly/external-sources/src/hook.rs | 351 ++++++++ .../assembly/external-sources/src/lib.rs | 2 + .../assembly/external-sources/src/refresh.rs | 4 + .../tests/hook_coordinator.rs | 217 +++++ .../contracts/product-domains/Cargo.toml | 5 + .../src/external_hook_catalog.rs | 325 ++++++++ .../src/external_hook_contributions.rs | 4 +- .../product-domains/src/external_sources.rs | 1 + .../contracts/product-domains/src/lib.rs | 3 + .../tests/external_hook_catalog_contracts.rs | 156 ++++ .../api/service-api/ExternalHooksAPI.test.ts | 182 +++++ .../api/service-api/ExternalHooksAPI.ts | 368 +++++++++ .../api/service-api/ExternalSourcesAPI.ts | 20 +- .../config/components/ExternalHooksPanel.scss | 184 +++++ .../components/ExternalHooksPanel.test.tsx | 395 +++++++++ .../config/components/ExternalHooksPanel.tsx | 517 ++++++++++++ .../components/ExternalSourcesConfig.test.tsx | 22 +- .../components/ExternalSourcesConfig.tsx | 12 +- .../en-US/settings/external-sources.json | 83 +- .../zh-CN/settings/external-sources.json | 83 +- .../zh-TW/settings/external-sources.json | 83 +- 69 files changed, 8833 insertions(+), 63 deletions(-) create mode 100644 src/apps/cli/src/modes/chat/external_hooks.rs create mode 100644 src/apps/desktop/src/api/external_hooks_api.rs create mode 100644 src/crates/adapters/claude-code-adapter/AGENTS.md create mode 100644 src/crates/adapters/claude-code-adapter/Cargo.toml create mode 100644 src/crates/adapters/claude-code-adapter/src/hook_source.rs create mode 100644 src/crates/adapters/claude-code-adapter/src/lib.rs create mode 100644 src/crates/adapters/claude-code-adapter/tests/hook_source.rs create mode 100644 src/crates/adapters/codex-adapter/AGENTS.md create mode 100644 src/crates/adapters/codex-adapter/Cargo.toml create mode 100644 src/crates/adapters/codex-adapter/src/hook_source.rs create mode 100644 src/crates/adapters/codex-adapter/src/lib.rs create mode 100644 src/crates/adapters/codex-adapter/tests/hook_source.rs create mode 100644 src/crates/adapters/opencode-adapter/src/hook_source.rs create mode 100644 src/crates/adapters/opencode-adapter/tests/hook_source.rs create mode 100644 src/crates/adapters/static-hook-support/Cargo.toml create mode 100644 src/crates/adapters/static-hook-support/src/lib.rs create mode 100644 src/crates/adapters/static-hook-support/tests/parser.rs create mode 100644 src/crates/assembly/core/src/external_hooks.rs create mode 100644 src/crates/assembly/core/src/external_hooks_tests.rs create mode 100644 src/crates/assembly/external-sources/src/hook.rs create mode 100644 src/crates/assembly/external-sources/tests/hook_coordinator.rs create mode 100644 src/crates/contracts/product-domains/src/external_hook_catalog.rs create mode 100644 src/crates/contracts/product-domains/tests/external_hook_catalog_contracts.rs create mode 100644 src/web-ui/src/infrastructure/api/service-api/ExternalHooksAPI.test.ts create mode 100644 src/web-ui/src/infrastructure/api/service-api/ExternalHooksAPI.ts create mode 100644 src/web-ui/src/infrastructure/config/components/ExternalHooksPanel.scss create mode 100644 src/web-ui/src/infrastructure/config/components/ExternalHooksPanel.test.tsx create mode 100644 src/web-ui/src/infrastructure/config/components/ExternalHooksPanel.tsx diff --git a/Cargo.toml b/Cargo.toml index fc49f19b4f..ec562bf7b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,9 @@ members = [ "src/crates/assembly/external-sources", "src/crates/adapters/ai-adapters", "src/crates/adapters/opencode-adapter", + "src/crates/adapters/claude-code-adapter", + "src/crates/adapters/codex-adapter", + "src/crates/adapters/static-hook-support", "src/crates/adapters/webdriver", "src/crates/adapters/transport", "src/crates/services/services-core", diff --git a/docs/architecture/extensions/external-ai-work-sources-design.md b/docs/architecture/extensions/external-ai-work-sources-design.md index 5ca5607866..c7746b9acc 100644 --- a/docs/architecture/extensions/external-ai-work-sources-design.md +++ b/docs/architecture/extensions/external-ai-work-sources-design.md @@ -18,8 +18,9 @@ prompt-only Command。第二条纵向切片已让受支持的单文件 OpenCode 已把 OpenCode 用户/项目 MCP 的 local stdio 与 HTTPS remote 安全子集接入现有 MCP owner,沿用显式审批、冲突、 工作区隔离和失败回推;现有 Skill Registry 另行展示来源、用户/项目作用域和固定优先级产生的覆盖结果,不并入上述 可执行来源选择规则。完整 -TypeScript/Bun、包依赖、package plugin、Codex/Claude Code 适配器、primary agent 替换和外部 Subagent 续接仍属于 -后续阶段,不能因来源被识别就宣称已经可用。 +TypeScript/Bun、包依赖、package plugin 执行、Codex/Claude Code 运行时适配、primary agent 替换和外部 Subagent +续接仍属于后续阶段,不能因来源被识别就宣称已经可用。OpenCode、Claude Code 与 Codex 的本地 Hook 静态目录 +已作为独立只读切片接入;它只证明来源和声明能够安全展示,不证明 handler 已加载、获得权限或可以执行。 ## 1. 产品判断与竞品启示 @@ -98,7 +99,7 @@ TypeScript/Bun、包依赖、package plugin、Codex/Claude Code 适配器、prim | 信息 | 产品要求 | |---|---| | 来源 | 产品、规范化位置、用户全局/项目/工作区作用域、实际执行域;Agent 普通视图只接收 `/…`、`~/.config/…` 或 `/…` 等安全标签,不传绝对用户路径。 | -| 内容 | 配置、Rules、Agents、Skills、Commands、MCP、插件、工具等类别与数量。 | +| 内容 | 配置、Rules、Agents、Skills、Commands、MCP、Hooks、插件、工具等类别与数量。 | | 状态 | 已发现、已应用、可用、需确认、更新中、沿用上一版本、部分受限、暂时过期、已移除/已停用或不可用。 | | 变化 | 最近成功读取时间、候选摘要、已应用摘要、权限或能力变化。 | | 操作 | 查看详情、应用/启用、按项目或执行域抑制/恢复兼容来源、进入/退出 Safe Mode、停用执行 target、撤销显式导入、重新加载、低风险/代码更新改为先询问、显式导入为 BitFun 配置。 | @@ -123,6 +124,39 @@ GUI 和 TUI 都通过同一个 `SetSafeMode` 动作请求该变化,Peer Host 显式导入完成后,已选字段由 BitFun 原生配置拥有,不再同时叠加外部值;未导入内容仍可继续作为兼容来源。 插件、Tool 和 Hook 不通过配置复制获得执行资格。 +### 3.4 静态 Hook 目录 + +Hook 首先以独立、只读的 `ExternalHookCatalogSnapshotV1` 展示,而不进入可执行来源控制面。Desktop 设置页的 +“外部 AI 应用 → Hooks”和交互式 TUI 的 `/hooks` 消费同一份 Rust 快照;`/help hooks`、`/hooks -h` 与 `/hooks --help` 提供说明,不增加快捷键、 +命名空间变体或生态专用命令。`/hooks` 与其他内置命令采用同一套既有冲突策略:无冲突时使用普通命令名;发生 +同名冲突时由现有命令菜单展示来源限定项,静态 Hook 目录不增加另一套保留字或路由规则。 + +当前目录的来源与降级边界如下: + +| 生态 | 当前静态来源 | 当前可见事实 | 明确不做 | +|---|---|---|---| +| OpenCode | 用户、legacy、项目祖先 `.opencode` 与显式配置目录中的 `plugin/`、`plugins/`;相同层级的 JSON/JSONC `plugin` 声明 | 以稳定、确定的 adapter 顺序展示每个具名导出的静态对象属性、未知属性和动态注册提示;相同事件在不同具名导出中保留独立注册身份;软件包声明只显示“已声明、未解析”;显式配置目录保持原生的末级优先顺序 | 不安装依赖、不解析软件包导出、不 import JS/TS、不执行 handler;类型声明 `.d.ts` 不作为运行时插件源;不把项目根下任意 `plugin(s)/` 当成 OpenCode 目录。 | +| Claude Code | 用户 `~/.claude/settings.json`,以及从项目根到当前工作区的 `.claude/settings.json`、`.claude/settings.local.json` | Hook 事件、matcher、`command/http/mcp_tool/prompt/agent` handler 类型;先合并已观察设置层,再把有效 `disableAllHooks=true` 投影为 disabled | 不复制 managed Hook 例外或运行时信任判断;任一参与层无效或超限时不猜测有效激活状态;不传输 command、prompt、URL、server/tool 参数或其他 handler 正文。 | +| Codex | 用户与按持久 `project_root_markers` 有界的项目祖先 `hooks.json`、`config.toml`;linked worktree 的 `hooks.json` 和 `config.toml` 中整个 `[hooks]` 表均映射到主 checkout 对应目录 | 展示固定 schema 中的 Hook 事件与 command handler;非原生 prompt/agent 声明只标为 unsupported;User state/feature 仍可能被未观察的 SessionFlags 覆盖,项目声明还受 trust 约束,因此 command handler 的有效激活统一保持 unknown | 不把 Claude Code 的 matcher 或全局禁用字段套用到 Codex;不猜测插件、托管层、会话注入、state/feature 合并或 trust-gated 项目激活来源,这些未观察来源以覆盖诊断明确显示。 | + +只有语义完全一致的 `PreToolUse`/`PostToolUse` 和 OpenCode `tool.execute.before`/`tool.execute.after` 分别映射到 +BitFun 已有 `ToolBefore`/`ToolAfter` 契约。其他原生事件仍可见,但标为 `native_only`;静态分析不能安全确定的注册 +标为 `opaque`,不得猜测映射。目录 DTO 只包含 provider 身份与稳定 adapter 顺序、来源、作用域、脱敏位置、matcher 摘要、 +handler 类型、原生激活状态、覆盖投影状态和固定诊断,不包含 handler body、命令、prompt、URL、环境变量、凭据 +或任意执行 payload。`content_version` 仅指纹化这些已脱敏语义事实,原始文件字节和敏感正文不进入版本值。 + +发现按文件、目录项、软件包声明、handler 和总目录条目设置硬上限;单个文件或 provider 失败只产生 Hook 资产诊断, +健康 provider 仍然发布。刷新失败时协调器保留该 provider 的最后有效静态结果并显式标记 stale;首次失败使用独立 +provider 失败事实,避免在空目录界面中伪装成“成功但没有来源”,错误正文仍只保留在共享诊断中。Desktop 和 TUI +只按需刷新,并复用已有类型化 Discovery Lane 的 provider 级合并、超时和延后结果机制,不建立第二套 watcher、 +任务调度器或状态机。同一工作区有发现仍在运行时,后续 Desktop/TUI 刷新只读取共享 pending 快照,不再排队第二代 +发现;超时后的终态在既有 refresh gate 内原子完成、发布。Desktop 轮询同一缓存快照直至 pending 结束,TUI 在单次 +命令内等待该快照收敛。GUI 对每个 provider 的来源、条目、诊断以及目录级诊断分别使用共享分页预算,TUI 对来源、 +条目和诊断设置输出上限,避免大型目录一次挂载或打印数千项。Git/worktree 服务只提供当前 checkout 边界与主 checkout 身份;adapter +在最多 32 层的边界内解释各生态祖先规则,无法确认 Git 边界时只读取当前工作区,不向任意父目录扩散。当前只允许 +本机执行域:Remote workspace 和 Peer Device Mode 显示明确不支持,绝不 +回退读取控制端本地同名配置。Server、Mobile、SDK、ACP、Host 和任何 Hook Runtime 都不属于该切片。 + ## 4. 来源、资产与加载策略 ### 4.1 来源身份与作用域 @@ -247,6 +281,7 @@ flowchart LR | 外部来源目录 | 聚合来源身份、作用域、资产清单、用户处理偏好和可读状态 | 解释所有生态格式、保存凭据、授予脚本权限或管理 worker。 | | 生态发现与解析适配器 | 发现本生态标准来源,保留真实优先级、格式、参数展开和诊断,并通过能力专属 provider 输出 | 写 BitFun 配置、依赖兄弟生态 adapter、执行其他生态语义或创建跨生态最低公分母。 | | 能力专属 provider 契约 | 用来源限定身份交付 Command、Tool、Subagent 等类型化定义与调用/展开结果 | 携带任意 payload 的通用资产对象,或让一种能力的新增字段污染其他能力。 | +| 静态 Hook provider 与目录协调器 | 通过 runtime-free 契约聚合 OpenCode、Claude Code、Codex 的脱敏声明;隔离 provider 失败、保留最后有效结果并向 Desktop/TUI 投影 | import handler、选择脚本运行时、授予执行权限、复用可执行来源审批 DTO,或把静态映射宣称为运行时兼容。 | | 文件观察服务 | 提供可订阅、去抖的文件变化事实 | 解释生态路径、决定优先级、提交业务状态。 | | 本地 JSON 存储服务 | 提供跨进程锁、锁内读改写和严格同卷原子替换等通用文件能力;替换失败时保留旧文件 | 定义外部来源偏好 schema、冲突策略或生态语义。 | | 共享生命周期协调器 | 由单一 `ExternalSourceControlPlane` 持有 Command、Tool、Subagent、MCP 四个 typed coordinator 和四条 discovery lane;按 provider 复用唯一 in-flight 请求、隔离超时和失败、以 generation fencing 拒绝迟到结果,再请求能力 owner 切换 | 按生态 ID 分支业务行为、把四类 payload 合并为通用资产、解析生态文件、直接提交配置、工具、权限或界面状态。 | @@ -386,6 +421,17 @@ Command;明确缺失且未被标记失败的 Command 是稳定删除。产品 上下文的外部 runtime id;外部服务器发起的 roots、sampling 和 elicitation 请求也 fail-closed,防止跨工作区读取或借用 BitFun 宿主能力。后续若接入这些能力,必须先补独立契约、工作区路由与权限交互,不能复用全局连接绕过当前边界。 +独立的静态 Hook 目录切片不依赖上述运行时阶段: + +1. `product-domains` 只定义 runtime-free DTO 和 capability-specific provider port;三个生态 adapter 各自解释来源, + `assembly/external-sources` 负责 provider 聚合与 last-valid,`assembly/core` 只负责本地产品装配、Git/worktree 边界 + 解析,以及在既有 refresh gate 内按工作区串行完成发现与发布。 +2. Desktop/Web UI 与交互式 TUI 只消费共享快照;无 Server、Remote、Peer、Mobile、SDK 或运行时投影。 +3. 验收覆盖脱敏序列化、部分失败、首次失败与空目录区分、非法/过大输入、有界枚举、provider 身份冲突、刷新竞态、stale 结果、保留命令、 + `/help hooks`、GUI 空/错/刷新/不支持状态,以及 Host 返回未知 v1 枚举或可执行字段时 fail closed。 +4. 后续覆盖率、版本差异和冲突分析在此目录之上增加只读派生事实;Hook 执行 Host、权限、顺序、取消与故障恢复必须 + 另立运行时切片,不能通过扩展本目录 DTO 偷渡执行语义。 + 验收至少覆盖: - 项目打开和无关会话不会因发现、解析、依赖准备或确认等待被阻塞。 diff --git a/docs/architecture/extensions/opencode-extension-compatibility.md b/docs/architecture/extensions/opencode-extension-compatibility.md index 902c71097c..6a1112ff34 100644 --- a/docs/architecture/extensions/opencode-extension-compatibility.md +++ b/docs/architecture/extensions/opencode-extension-compatibility.md @@ -147,23 +147,29 @@ OpenCode,和 OpenCode 配置/插件进入 BitFun 是两个独立验收方向 ### 3.3 稳定服务 Hook +本节的“实现”指进入真实 OpenCode 插件运行时。BitFun 当前按插件声明与具名导出顺序,从本地插件文件静态展示 +下列 Hook 属性,并把 `tool.execute.before/after` 投影到已有 Tool Hook 点;未知或动态注册保持 `opaque`。映射仅表示 +BitFun 已识别等价契约覆盖,不表示外部 handler 已加载、激活或执行。目录不会 import 或执行插件,内容版本也只 +指纹化脱敏后的目录事实,因此不改变任何 Hook Runtime 的“未实现”结论。`tool` 是工具注册能力,不作为 Hook +事件猜测或投影。 + | Hook | BitFun 差异 | 当前状态 | 目标可实现性 | 成熟度依赖(非执行顺序) | BitFun 需要完成的工作 | |---|---|---|---|---|---| -| `dispose` | 直接桥接 | 未实现 | 可完整适配 | OC-R3 | 调用清理并设置期限;超时回收 worker。 | -| `event` | 补扩展接口 | 未实现 | 可完整适配 | OC-R3 | 提供版本化事件代理并隔离插件异常。 | -| `config` | 补扩展接口 + 融合现有能力 | 未实现 | 可完整适配 | OC-R3 | 按插件顺序变换,最后由 Config 归属模块校验提交。 | +| `dispose` | 直接桥接 | 静态目录可见,运行未实现 | 可完整适配 | OC-R3 | 调用清理并设置期限;超时回收 worker。 | +| `event` | 补扩展接口 | 静态目录可见,运行未实现 | 可完整适配 | OC-R3 | 提供版本化事件代理并隔离插件异常。 | +| `config` | 补扩展接口 + 融合现有能力 | 静态目录可见,运行未实现 | 可完整适配 | OC-R3 | 按插件顺序变换,最后由 Config 归属模块校验提交。 | | `tool` | 补基础能力 + 补扩展接口 | 未实现 | 可完整适配 | OC-R2 | 注册真实工具定义与执行函数。 | -| `auth` | 补扩展接口 | 未实现 | 可主要适配 | OC-R3 | 提供 API/OAuth 方法和脱敏凭据代理。 | -| `provider` | 补扩展接口 + 融合现有能力 | 未实现 | 可主要适配 | OC-R3 | 将动态模型列表接入 Provider 归属模块。 | -| `chat.message` | 补扩展接口 | 未实现 | 可完整适配 | OC-R3 | 依次变换消息和 parts,变换后重做结构校验。 | -| `chat.params` | 补扩展接口 + 融合现有能力 | 未实现 | 可完整适配 | OC-R3 | 依次变换模型参数,显式产品上限最后生效。 | -| `chat.headers` | 补扩展接口 | 未实现 | 可完整适配 | OC-R3 | 依次变换请求头,敏感值不进入日志。 | -| `permission.ask` | 融合现有能力 | 未实现 | 可主要适配 | OC-R3 | 默认保留 allow/deny/ask 语义;用户或组织策略可收紧。 | -| `command.execute.before` | 补扩展接口 | 未实现 | 可完整适配 | OC-R3 | 在命令执行前依次变换消息 parts。 | -| `tool.execute.before` | 补扩展接口 | 未实现 | 可完整适配 | OC-R3 | 变换最终参数,随后重做 schema 和权限判断。 | -| `shell.env` | 补扩展接口 | 未实现 | 可完整适配 | OC-R3 | 在实际执行域构造环境变量。 | -| `tool.execute.after` | 补扩展接口 | 未实现 | 可完整适配 | OC-R3 | 依次变换 title、output、metadata,保留原始结果引用。 | -| `tool.definition` | 补扩展接口 + 融合现有能力 | 未实现 | 可完整适配 | OC-R3 | 变换模型可见 JSON Schema;真实执行继续使用 worker 中原始 Zod 校验,保持 OpenCode 双表示语义。 | +| `auth` | 补扩展接口 | 静态目录可见,运行未实现 | 可主要适配 | OC-R3 | 提供 API/OAuth 方法和脱敏凭据代理。 | +| `provider` | 补扩展接口 + 融合现有能力 | 静态目录可见,运行未实现 | 可主要适配 | OC-R3 | 将动态模型列表接入 Provider 归属模块。 | +| `chat.message` | 补扩展接口 | 静态目录可见,运行未实现 | 可完整适配 | OC-R3 | 依次变换消息和 parts,变换后重做结构校验。 | +| `chat.params` | 补扩展接口 + 融合现有能力 | 静态目录可见,运行未实现 | 可完整适配 | OC-R3 | 依次变换模型参数,显式产品上限最后生效。 | +| `chat.headers` | 补扩展接口 | 静态目录可见,运行未实现 | 可完整适配 | OC-R3 | 依次变换请求头,敏感值不进入日志。 | +| `permission.ask` | 融合现有能力 | 静态目录可见,运行未实现 | 可主要适配 | OC-R3 | 默认保留 allow/deny/ask 语义;用户或组织策略可收紧。 | +| `command.execute.before` | 补扩展接口 | 静态目录可见,运行未实现 | 可完整适配 | OC-R3 | 在命令执行前依次变换消息 parts。 | +| `tool.execute.before` | 补扩展接口 | 静态映射可见,运行未实现 | 可完整适配 | OC-R3 | 变换最终参数,随后重做 schema 和权限判断。 | +| `shell.env` | 补扩展接口 | 静态目录可见,运行未实现 | 可完整适配 | OC-R3 | 在实际执行域构造环境变量。 | +| `tool.execute.after` | 补扩展接口 | 静态映射可见,运行未实现 | 可完整适配 | OC-R3 | 依次变换 title、output、metadata,保留原始结果引用。 | +| `tool.definition` | 补扩展接口 + 融合现有能力 | 静态目录可见,运行未实现 | 可完整适配 | OC-R3 | 变换模型可见 JSON Schema;真实执行继续使用 worker 中原始 Zod 校验,保持 OpenCode 双表示语义。 | Hook 的共同风险是把变换误做成通知、并行调用破坏顺序或插件写入非法状态。所有 Hook 都走类型化调用、顺序执行和归属模块终检;具体调用协议见[服务插件运行时设计](opencode-plugin-runtime-adapter-design.md#6-钩子适配与权威提交)。 diff --git a/docs/architecture/product-architecture.md b/docs/architecture/product-architecture.md index 3e45018830..7d4bf77842 100644 --- a/docs/architecture/product-architecture.md +++ b/docs/architecture/product-architecture.md @@ -265,7 +265,8 @@ Plugin Runtime P0 只验证了 BitFun 专用插件目录中的来源校验、工 选择后接入现有 Task/Subagent owner,并以 generation lease 固定 fresh single-run 调用;MCP 可把受支持的用户/项目 配置经确认和同名冲突选择后交给现有 MCP owner 运行。四类贡献对象互不复用,主体逻辑不按生态分支。当前仍不表示 package plugin、Hook、primary agent、外部 agent 续接、SSH Remote 工作区来源发现或完整 -配置兼容已经可用。 +配置兼容已经可用。独立的静态 Hook 目录可以发现并脱敏展示 OpenCode、Claude Code 与 Codex 的本地声明,但不加载 +handler、不授予权限,也不改变这里对 Hook Runtime“尚不可用”的判断。 目标路线不要求 OpenCode 插件作者维护 `bitfun.plugin.json` 或复制到 `.bitfun/plugins`。BitFun 直接发现用户和 项目的 OpenCode 配置、插件目录、工具目录和软件包来源;低风险内容按用户偏好自动应用或先询问,可执行来源在 @@ -355,7 +356,7 @@ flowchart LR | 产品形态 | 当前扩展能力 | 入口行为 | |---|---|---| | Desktop / product-full | 生产入口仍依赖 `bitfun-core/product-full` 作为兼容组装层;Beta“外部 AI 应用”设置消费版本化 control/catalog 同代快照,显示正交生命周期、审批、冲突、诊断和 Host 能力,并可切换易失 Safe Mode;MCP 原生清单优先展示,外部候选保持独立来源、作用域和覆盖状态;Skills 场景显示已发现 Skill 的生态来源、用户/项目作用域与覆盖结果 | 当前四条可执行纵向切片由事实所在 Host 执行;本机 Desktop 使用本机 Host,Peer 控制界面代理 Peer Host,不在控制端回退发现或执行。Safe Mode 只撤下外部 Tool/Subagent/MCP 新调用,不改写来源偏好、不停止发现也不取消在途调用;状态仅在当前 Host 进程/执行域有效,重启或切换 Host 后关闭,所有产品面必须明确提示。Skill 仍使用独立 Registry;受管 package plugin 仍只有静态预览 | -| CLI | 入口仍以 `bitfun-core/product-full` 作为执行兼容 owner;交互式 TUI 已可执行受支持的 Prompt Command,`/extensions` 的 status、refresh、Safe Mode 与 source enable/disable 消费同一 v1 控制 DTO,`/tools` 与 `/agents` 继续承载能力专属查看和操作;命令结果复用现有异步事件循环,不新增调度器。Skill 列表显示来源,模式配置按实际选择结果说明覆盖来源 | 已批准的 standalone Tool 进入现有 Tool Runtime;已批准的外部 Subagent 只支持 fresh single-run。CLI/TUI 不解析生态文件、不启动第二套 worker/Agent owner;Safe Mode 操作后重读权威 catalog 再更新命令路由。非交互入口和 SSH Remote 未接入时不得借本机 TUI 路径代执行;其余 Runtime Parts/Rust Runtime SDK、会话和工作区 owner 边界保持不变 | +| CLI | 入口仍以 `bitfun-core/product-full` 作为执行兼容 owner;交互式 TUI 已可执行受支持的 Prompt Command,`/extensions` 的 status、refresh、Safe Mode 与 source enable/disable 消费同一 v1 控制 DTO,`/hooks` 异步读取与 Desktop 相同的只读 Hook 快照并遵循既有命令冲突策略,`/tools` 与 `/agents` 继续承载能力专属查看和操作;命令结果复用现有异步事件循环和 Discovery Lane,不新增调度器。Skill 列表显示来源,模式配置按实际选择结果说明覆盖来源 | 已批准的 standalone Tool 进入现有 Tool Runtime;已批准的外部 Subagent 只支持 fresh single-run。CLI/TUI 不解析生态文件、不启动第二套 worker/Agent owner;Hook 的 coverage mapping 只表示契约覆盖,不表示外部 handler 已加载、激活或执行。Safe Mode 操作后重读权威 catalog 再更新命令路由。非交互入口和 SSH Remote 未接入时不得借本机 TUI 路径代执行;其余 Runtime Parts/Rust Runtime SDK、会话和工作区 owner 边界保持不变 | | HarmonyOS PC 原生 CLI/TUI | 未来平台目标,当前未实现 | 目标、问题和风险见平台规约;具体适配另立专题,HAP、手机 Remote App 与远端代执行均不替代 | | HarmonyOS PC GUI | 完整 HarmonyOS PC 支持的另一目标形态,当前未实现 | 与 CLI/TUI 共享稳定能力和 Runtime 语义,但独立设计宿主、界面与发布验证;Web、Remote 或现有 Tauri Desktop 均不能替代 | | HarmonyOS 手机 Remote App | `src/apps/mobile/harmonyos` 是 phone-only ArkTS 远程入口,不持有本地 Rust Agent Runtime | 保持当前能力并按移动端专题独立演进;本轮不提前设计移动 Runtime/TUI/GUI,也不能据此宣称 HarmonyOS PC 本地能力 | diff --git a/scripts/core-boundaries/rules/crate-layout.mjs b/scripts/core-boundaries/rules/crate-layout.mjs index db66be6be2..8c5c9904c6 100644 --- a/scripts/core-boundaries/rules/crate-layout.mjs +++ b/scripts/core-boundaries/rules/crate-layout.mjs @@ -28,7 +28,10 @@ export const crateLayoutRules = [ { crateName: 'acp', layer: 'interfaces', path: 'src/crates/interfaces/acp' }, { crateName: 'ai-adapters', layer: 'adapters', path: 'src/crates/adapters/ai-adapters' }, + { crateName: 'claude-code-adapter', layer: 'adapters', path: 'src/crates/adapters/claude-code-adapter' }, + { crateName: 'codex-adapter', layer: 'adapters', path: 'src/crates/adapters/codex-adapter' }, { crateName: 'opencode-adapter', layer: 'adapters', path: 'src/crates/adapters/opencode-adapter' }, + { crateName: 'static-hook-support', layer: 'adapters', path: 'src/crates/adapters/static-hook-support' }, { crateName: 'transport', layer: 'adapters', path: 'src/crates/adapters/transport' }, { crateName: 'webdriver', layer: 'adapters', path: 'src/crates/adapters/webdriver' }, diff --git a/scripts/core-boundaries/rules/crate-rules.mjs b/scripts/core-boundaries/rules/crate-rules.mjs index b791ca48e5..1705b3e6ef 100644 --- a/scripts/core-boundaries/rules/crate-rules.mjs +++ b/scripts/core-boundaries/rules/crate-rules.mjs @@ -17,7 +17,10 @@ export const noCoreDependencyCrates = [ 'agent-tools', 'tool-packs', 'product-domains', + 'claude-code-adapter', + 'codex-adapter', 'opencode-adapter', + 'static-hook-support', 'external-sources', 'terminal', 'tool-runtime', @@ -39,6 +42,33 @@ export const forbiddenManifestDependencyRules = [ message: 'only bitfun-core product-full assembly may register bitfun-opencode-adapter through reviewed capability composition roots', }, + ...[ + ['bitfun-claude-code-adapter', 'claude-code-adapter'], + ['bitfun-codex-adapter', 'codex-adapter'], + ].map(([dependencyName, crateName]) => ({ + dependencyNames: [dependencyName], + scanRoots: ['src/apps', 'src/crates', 'BitFun-Installer/src-tauri'], + workspaceManifestPath: 'Cargo.toml', + allowManifestPaths: [ + `src/crates/adapters/${crateName}/Cargo.toml`, + 'src/crates/assembly/core/Cargo.toml', + ], + reason: `${crateName} production dependencies are limited to the reviewed product composition root`, + message: `only bitfun-core product-full assembly may register ${dependencyName} through the static Hook provider boundary`, + })), + { + dependencyNames: ['bitfun-static-hook-support'], + scanRoots: ['src/apps', 'src/crates', 'BitFun-Installer/src-tauri'], + workspaceManifestPath: 'Cargo.toml', + allowManifestPaths: [ + 'src/crates/adapters/static-hook-support/Cargo.toml', + 'src/crates/adapters/opencode-adapter/Cargo.toml', + 'src/crates/adapters/claude-code-adapter/Cargo.toml', + 'src/crates/adapters/codex-adapter/Cargo.toml', + ], + reason: 'static Hook parsing support is private to ecosystem source adapters', + message: 'static Hook support may only be consumed by reviewed ecosystem Hook adapters', + }, ]; export const lightweightBoundaryRules = [ diff --git a/scripts/core-boundaries/rules/source/forbidden-rules.mjs b/scripts/core-boundaries/rules/source/forbidden-rules.mjs index 767114adb8..c819034497 100644 --- a/scripts/core-boundaries/rules/source/forbidden-rules.mjs +++ b/scripts/core-boundaries/rules/source/forbidden-rules.mjs @@ -4119,8 +4119,10 @@ export const forbiddenContentUnderRules = [ 'src/crates/adapters/opencode-adapter/tests/tool_source_contracts.rs', 'src/crates/adapters/opencode-adapter/tests/opencode_subagent_adapter.rs', 'src/crates/adapters/opencode-adapter/tests/opencode_mcp_adapter.rs', + 'src/crates/adapters/opencode-adapter/tests/hook_source.rs', 'src/crates/assembly/core/src/plugin_runtime.rs', 'src/crates/assembly/core/src/external_sources.rs', + 'src/crates/assembly/core/src/external_hooks.rs', ], message: 'only a reviewed product composition root may import bitfun-opencode-adapter through a capability-specific provider boundary', @@ -4140,6 +4142,44 @@ export const forbiddenContentUnderRules = [ }, ], }, + { + path: 'src', + reason: 'Claude Code static Hook adapter imports are limited to its fixtures and composition root', + patterns: [{ + regex: /\b(?:use\s+bitfun_claude_code_adapter\b|extern\s+crate\s+bitfun_claude_code_adapter\b|bitfun_claude_code_adapter::)/, + allowPaths: [ + 'src/crates/adapters/claude-code-adapter/tests/hook_source.rs', + 'src/crates/assembly/core/src/external_hooks.rs', + ], + message: 'Claude Code Hook adapter may only be imported by its fixtures and the reviewed composition root', + }], + }, + { + path: 'src', + reason: 'Codex static Hook adapter imports are limited to its fixtures and composition root', + patterns: [{ + regex: /\b(?:use\s+bitfun_codex_adapter\b|extern\s+crate\s+bitfun_codex_adapter\b|bitfun_codex_adapter::)/, + allowPaths: [ + 'src/crates/adapters/codex-adapter/tests/hook_source.rs', + 'src/crates/assembly/core/src/external_hooks.rs', + ], + message: 'Codex Hook adapter may only be imported by its fixtures and the reviewed composition root', + }], + }, + { + path: 'src', + reason: 'shared static Hook support is private to reviewed ecosystem Hook adapters', + patterns: [{ + regex: /\b(?:use\s+bitfun_static_hook_support\b|extern\s+crate\s+bitfun_static_hook_support\b|bitfun_static_hook_support::)/, + allowPaths: [ + 'src/crates/adapters/static-hook-support/tests/parser.rs', + 'src/crates/adapters/opencode-adapter/src/hook_source.rs', + 'src/crates/adapters/claude-code-adapter/src/hook_source.rs', + 'src/crates/adapters/codex-adapter/src/hook_source.rs', + ], + message: 'static Hook parsing support may only be imported by reviewed ecosystem Hook adapters', + }], + }, { path: 'src/crates/assembly/core/src', reason: diff --git a/scripts/core-boundaries/rules/source/public-api-rules.mjs b/scripts/core-boundaries/rules/source/public-api-rules.mjs index c3b787f8c7..fd1b5e82fa 100644 --- a/scripts/core-boundaries/rules/source/public-api-rules.mjs +++ b/scripts/core-boundaries/rules/source/public-api-rules.mjs @@ -181,6 +181,23 @@ function opencodeAdapterEntry(symbol, consumer) { }; } +function opencodeHookAdapterEntry(symbol, consumer) { + return { + symbol, + owner: 'opencode-adapter static Hook source owner', + consumer, + verification: + 'OpenCode static Hook fixture tests, bitfun-core catalog composition tests, and core-boundary public API budget checks', + p0: 'runtime-free OpenCode Hook discovery and catalog projection', + contractSlice: contractSlices.opencodeAdapterBoundary, + wireImpact: false, + rationale: + 'the product catalog needs one OpenCode-specific parser behind the ecosystem-neutral external Hook provider contract', + exit: + 'remove only if OpenCode Hook discovery moves behind another reviewed adapter with equivalent redaction and fail-closed tests', + }; +} + export const opencodeAdapterPublicApiEntries = [ opencodeAdapterEntry( 'load_opencode_package_adapter', @@ -218,22 +235,80 @@ export const opencodeAdapterPublicApiEntries = [ 'OpenCodeMcpProviderOptions', 'OpenCode MCP adapter fixture tests and explicit environment injection', ), + opencodeHookAdapterEntry( + 'OpenCodeHookProvider', + 'bitfun-core external Hook catalog composition root and OpenCode static Hook fixtures', + ), + opencodeHookAdapterEntry( + 'OpenCodeHookProviderOptions', + 'OpenCode static Hook fixture tests and explicit environment injection', + ), ]; +function staticHookAdapterEntry(symbol, owner, consumer) { + return { + symbol, + owner, + consumer, + verification: 'ecosystem Hook fixtures and core-boundary public API budget checks', + p0: 'runtime-free static Hook discovery', + contractSlice: contractSlices.externalSourceHookContract, + wireImpact: false, + rationale: 'the source adapter needs one narrow, redacted static-inspection surface', + exit: 'remove only with the corresponding static Hook source adapter', + }; +} + +export const claudeCodeHookAdapterPublicApiEntries = [ + 'ClaudeCodeHookProvider', + 'ClaudeCodeHookProviderOptions', +].map((symbol) => staticHookAdapterEntry( + symbol, + 'claude-code-adapter static Hook owner', + 'bitfun-core composition root and Claude Code Hook fixtures', +)); + +export const codexHookAdapterPublicApiEntries = [ + 'CodexHookProvider', + 'CodexHookProviderOptions', +].map((symbol) => staticHookAdapterEntry( + symbol, + 'codex-adapter static Hook owner', + 'bitfun-core composition root and Codex Hook fixtures', +)); + +export const staticHookSupportPublicApiEntries = [ + 'BoundedFileRead', + 'read_bounded_file', + 'regular_file_exists', + 'bounded_project_ancestors', + 'StaticHookDocumentFormat', + 'StaticHookHandlerRule', + 'StaticHookParseIssue', + 'StaticHookHandlerFact', + 'StaticHookParseResult', + 'redacted_parse_content_version', + 'parse_hook_document', +].map((symbol) => staticHookAdapterEntry( + symbol, + 'static-hook-support parser owner', + 'OpenCode, Claude Code, and Codex static Hook source adapters', +)); + function externalHookContractEntry(symbol, owner, consumer, wireImpact = false) { return { symbol, owner, consumer, verification: - 'product-domain Hook contract tests, OpenCode mapping tests, managed-package read-projection tests, and core-boundary checks', - p0: 'runtime-free external Hook source-projection contract', + 'product-domain Hook contract tests, ecosystem adapter fixtures, catalog coordinator tests, and core-boundary checks', + p0: 'runtime-free external Hook contribution and catalog contracts', contractSlice: contractSlices.externalSourceHookContract, wireImpact, rationale: - 'the production OpenCode managed-package read projection needs typed Hook identities and safety facts without runtime or ecosystem payload coupling', + 'static Hook inspection needs typed, redacted identities and safety facts without runtime or ecosystem payload coupling', exit: - 'remove with the OpenCode static Hook projection or through a reviewed contract migration with equivalent fail-closed mapping tests', + 'remove only through a reviewed Hook contract migration with equivalent redaction and fail-closed mapping tests', }; } @@ -251,6 +326,29 @@ export const externalHookContractPublicApiEntries = [ ), ); +export const externalHookCatalogPublicApiEntries = [ + 'EXTERNAL_HOOK_CATALOG_SCHEMA_V1', + 'ExternalHookSourceKind', + 'ExternalHookHandlerKind', + 'ExternalHookProjectionStatus', + 'ExternalHookNativeActivation', + 'ExternalHookMatcherSummary', + 'ExternalHookMapping', + 'ExternalHookProviderIdentity', + 'ExternalHookSource', + 'ExternalHookCatalogEntry', + 'ExternalHookProviderSnapshot', + 'ExternalHookSourceProvider', + 'ExternalHookCatalogSnapshotV1', +].map((symbol) => + externalHookContractEntry( + symbol, + 'product-domains external Hook catalog contract owner', + 'ecosystem Hook source adapters, external-sources catalog coordinator, bitfun-core, and read-only product surfaces', + true, + ), +); + function externalSourceEntry(symbol, owner, consumer, wireImpact = false) { return { symbol, @@ -556,6 +654,16 @@ export const externalSourceCoordinatorPublicApiEntries = [ 'external-sources assembly owner', 'bitfun-core product composition root', ), + externalHookContractEntry( + 'ExternalHookCatalogCoordinator', + 'external-sources Hook catalog coordinator owner', + 'bitfun-core local-workspace Hook catalog service', + ), + externalHookContractEntry( + 'ExternalHookDiscoveryResult', + 'external-sources Hook discovery scheduler owner', + 'bitfun-core local-workspace Hook catalog service', + ), ...['ExternalSourceDiscoveryRequest', 'ExternalSourceDiscoveryResult'].map((symbol) => externalSourceEntry( symbol, @@ -843,6 +951,21 @@ export const publicApiAllowlistRules = [ 'OpenCode adapter public API must stay limited to source and candidate mapping through the Plugin Runtime Host adapter boundary', allowedSymbolEntries: opencodeAdapterPublicApiEntries, }, + { + path: 'src/crates/adapters/claude-code-adapter/src/lib.rs', + reason: 'Claude Code adapter public API is limited to static Hook discovery', + allowedSymbolEntries: claudeCodeHookAdapterPublicApiEntries, + }, + { + path: 'src/crates/adapters/codex-adapter/src/lib.rs', + reason: 'Codex adapter public API is limited to static Hook discovery', + allowedSymbolEntries: codexHookAdapterPublicApiEntries, + }, + { + path: 'src/crates/adapters/static-hook-support/src/lib.rs', + reason: 'shared static Hook parsing helpers must stay narrow and redacted', + allowedSymbolEntries: staticHookSupportPublicApiEntries, + }, { path: 'src/crates/execution/plugin-runtime-host/src/lib.rs', reason: @@ -885,6 +1008,12 @@ export const publicApiAllowlistRules = [ 'external Hook contracts must stay ecosystem-neutral, runtime-free, fail-closed, and explicitly consumer-backed', allowedSymbolEntries: externalHookContractPublicApiEntries, }, + { + path: 'src/crates/contracts/product-domains/src/external_hook_catalog.rs', + reason: + 'external Hook catalog contracts must stay ecosystem-neutral, runtime-free, redacted, bounded, and explicitly consumer-backed', + allowedSymbolEntries: externalHookCatalogPublicApiEntries, + }, { path: 'src/crates/assembly/external-sources/src/lib.rs', reason: diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index 86fcebf6e3..bd8b63531b 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -870,12 +870,24 @@ export function runManifestParserSelfTest({ const opencodeAdapterPublicApiRule = publicApiAllowlistRules.find( (rule) => rule.path === 'src/crates/adapters/opencode-adapter/src/lib.rs', ); + const claudeHookAdapterPublicApiRule = publicApiAllowlistRules.find( + (rule) => rule.path === 'src/crates/adapters/claude-code-adapter/src/lib.rs', + ); + const codexHookAdapterPublicApiRule = publicApiAllowlistRules.find( + (rule) => rule.path === 'src/crates/adapters/codex-adapter/src/lib.rs', + ); + const staticHookSupportPublicApiRule = publicApiAllowlistRules.find( + (rule) => rule.path === 'src/crates/adapters/static-hook-support/src/lib.rs', + ); const externalSubagentPublicApiRule = publicApiAllowlistRules.find( (rule) => rule.path === 'src/crates/contracts/product-domains/src/external_subagents.rs', ); const externalHookPublicApiRule = publicApiAllowlistRules.find( (rule) => rule.path === 'src/crates/contracts/product-domains/src/external_hook_contributions.rs', ); + const externalHookCatalogPublicApiRule = publicApiAllowlistRules.find( + (rule) => rule.path === 'src/crates/contracts/product-domains/src/external_hook_catalog.rs', + ); const externalSourcePublicApiRule = publicApiAllowlistRules.find( (rule) => rule.path === 'src/crates/contracts/product-domains/src/external_sources.rs', ); @@ -1000,10 +1012,10 @@ export function runManifestParserSelfTest({ ).map((entry) => entry.symbol); if ( opencodeAdapterPublicApiSymbols.join(',') !== - 'load_opencode_package_adapter,OpenCodeCommandProvider,OpenCodeCommandProviderOptions,OpenCodeToolProvider,OpenCodeToolProviderOptions,OpenCodeSubagentProvider,OpenCodeSubagentProviderOptions,OpenCodeMcpProvider,OpenCodeMcpProviderOptions' + 'load_opencode_package_adapter,OpenCodeCommandProvider,OpenCodeCommandProviderOptions,OpenCodeToolProvider,OpenCodeToolProviderOptions,OpenCodeSubagentProvider,OpenCodeSubagentProviderOptions,OpenCodeMcpProvider,OpenCodeMcpProviderOptions,OpenCodeHookProvider,OpenCodeHookProviderOptions' ) { throw new Error( - 'OpenCode adapter public API budget must stay limited to the reviewed package factory and capability-specific command, tool, subagent, and MCP providers', + 'OpenCode adapter public API budget must stay limited to the reviewed package factory and capability-specific command, tool, subagent, MCP, and static Hook providers', ); } for (const entry of opencodeAdapterPublicApiRule.allowedSymbolEntries) { @@ -1019,12 +1031,26 @@ export function runManifestParserSelfTest({ throw new Error(`OpenCode adapter public API entry must not claim wire impact: ${entry.symbol}`); } } + for (const [label, rule, requiredSymbols] of [ + ['Claude Code Hook adapter', claudeHookAdapterPublicApiRule, ['ClaudeCodeHookProvider', 'ClaudeCodeHookProviderOptions']], + ['Codex Hook adapter', codexHookAdapterPublicApiRule, ['CodexHookProvider', 'CodexHookProviderOptions']], + ['static Hook support', staticHookSupportPublicApiRule, ['read_bounded_file', 'regular_file_exists', 'redacted_parse_content_version', 'parse_hook_document']], + ]) { + if (!rule || requiredSymbols.some((symbol) => !rule.allowedSymbolEntries.some( + (entry) => entry.symbol === symbol && entry.contractSlice === 'external-source-hook-contract', + ))) { + throw new Error(`${label} must have a narrow consumer-backed public API budget`); + } + } if (!externalSubagentPublicApiRule) { throw new Error('external subagent contracts must have an independent public API budget rule'); } if (!externalHookPublicApiRule) { throw new Error('external Hook contracts must have an independent public API budget rule'); } + if (!externalHookCatalogPublicApiRule) { + throw new Error('external Hook catalog contracts must have an independent public API budget rule'); + } if (!publicApiContractSlices.includes('external-source-hook-contract')) { throw new Error('external Hook contracts must have an independent contract slice'); } @@ -1045,6 +1071,23 @@ export function runManifestParserSelfTest({ throw new Error(`external Hook public API budget is missing a consumer-backed symbol: ${requiredSymbol}`); } } + for (const requiredSymbol of [ + 'EXTERNAL_HOOK_CATALOG_SCHEMA_V1', + 'ExternalHookProviderIdentity', + 'ExternalHookSourceProvider', + 'ExternalHookProviderSnapshot', + 'ExternalHookCatalogSnapshotV1', + ]) { + if (!externalHookCatalogPublicApiRule.allowedSymbolEntries.some( + (entry) => entry.symbol === requiredSymbol + && entry.contractSlice === 'external-source-hook-contract' + && entry.wireImpact === true + && entry.consumer + && entry.verification, + )) { + throw new Error(`external Hook catalog public API budget is missing a consumer-backed symbol: ${requiredSymbol}`); + } + } if (!publicApiContractSlices.includes('external-source-subagent-contract')) { throw new Error('external subagent contracts must have an independent contract slice'); } @@ -1080,6 +1123,19 @@ export function runManifestParserSelfTest({ throw new Error(`external source coordinator public API budget is missing control symbol: ${requiredSymbol}`); } } + if (!externalSourceCoordinatorPublicApiRule?.allowedSymbolEntries.some( + (entry) => entry.symbol === 'ExternalHookCatalogCoordinator' + && entry.contractSlice === 'external-source-hook-contract' + && entry.wireImpact === false, + )) { + throw new Error('external source coordinator public API budget is missing the static Hook catalog coordinator'); + } + if (!externalSourceCoordinatorPublicApiRule.allowedSymbolEntries.some( + (entry) => entry.symbol === 'ExternalHookDiscoveryResult' + && entry.contractSlice === 'external-source-hook-contract', + )) { + throw new Error('external source coordinator public API budget is missing the typed Hook discovery result'); + } for (const requiredSymbol of [ 'ExternalSourceControlSnapshotV1', 'ExternalSourceControlRequestV1', @@ -1156,6 +1212,18 @@ export function runManifestParserSelfTest({ if (!opencodeManifestRule) { throw new Error('OpenCode adapter must have a forbidden manifest dependency rule'); } + for (const dependencyName of [ + 'bitfun-claude-code-adapter', + 'bitfun-codex-adapter', + 'bitfun-static-hook-support', + ]) { + if (!forbiddenManifestDependencyRules.some( + (rule) => rule.dependencyNames?.includes(dependencyName) + && rule.workspaceManifestPath === 'Cargo.toml', + )) { + throw new Error(`${dependencyName} must have a workspace-wide manifest dependency guard`); + } + } for (const scanRoot of ['src/apps', 'src/crates', 'BitFun-Installer/src-tauri']) { if (!opencodeManifestRule.scanRoots?.includes(scanRoot)) { throw new Error(`OpenCode adapter manifest guard must scan ${scanRoot}`); @@ -1225,6 +1293,14 @@ export function runManifestParserSelfTest({ if (!noCoreDependencyCrates.includes('plugin-runtime-host')) { throw new Error('plugin-runtime-host must be covered by the no-core dependency guard'); } + for (const adapterCrate of ['claude-code-adapter', 'codex-adapter', 'static-hook-support']) { + if (crateLayoutRules.find((rule) => rule.crateName === adapterCrate)?.layer !== 'adapters') { + throw new Error(`${adapterCrate} must be registered in the adapter crate layout`); + } + if (!noCoreDependencyCrates.includes(adapterCrate)) { + throw new Error(`${adapterCrate} must be covered by the no-core dependency guard`); + } + } const pluginRuntimeHostRule = lightweightBoundaryRules.find( (rule) => rule.crateName === 'plugin-runtime-host', ); diff --git a/src/apps/cli/src/actions.rs b/src/apps/cli/src/actions.rs index 0f02e87d0d..0596eb6e61 100644 --- a/src/apps/cli/src/actions.rs +++ b/src/apps/cli/src/actions.rs @@ -67,6 +67,7 @@ pub(crate) enum ActionHandler { McpServers, Tools, Extensions, + Hooks, AcpHelp, Init, History, @@ -385,6 +386,21 @@ static ACTION_SPECS: &[ActionSpec] = &[ shortcut_label: None, slash_on_startup: false, }, + ActionSpec { + id: "hooks", + name: "Hooks", + aliases: &["/hooks"], + description: "Inspect external AI application Hooks", + contexts: CHAT, + availability: ActionAvailability::Always, + handler: ActionHandler::Hooks, + default_bindings: &[], + fallback_bindings: &[], + shortcut_field: None, + palette: palette("Tools", false), + shortcut_label: None, + slash_on_startup: false, + }, ActionSpec { id: "acp_help", name: "ACP setup", diff --git a/src/apps/cli/src/modes/chat.rs b/src/apps/cli/src/modes/chat.rs index 723150d454..c276437712 100644 --- a/src/apps/cli/src/modes/chat.rs +++ b/src/apps/cli/src/modes/chat.rs @@ -65,6 +65,10 @@ use bitfun_core::agentic::tools::implementations::skills::{ registry::SkillRegistry, ModeSkillInfo, SkillInfo, }; +use bitfun_core::external_hooks::{ + local_external_hook_catalog_snapshot, ExternalHookCatalogSnapshotV1, + ExternalHookMatcherSummary, ExternalHookNativeActivation, ExternalHookProjectionStatus, +}; use bitfun_core::external_sources::{ apply_external_source_control_action, choose_external_subagent_conflict, expand_external_prompt_command, external_source_conflict_choices, external_source_snapshot, @@ -82,6 +86,7 @@ use bitfun_core::external_sources::{ }; use bitfun_core::service::config::GlobalConfigManager; use bitfun_core::service::session_usage::render_usage_report_markdown; +use bitfun_product_domains::external_sources::{ExternalSourceHealth, ExternalSourceScope}; /// Spinner/UI redraw interval while a turn is processing. const SPINNER_REDRAW_INTERVAL_MS: u64 = 100; @@ -90,6 +95,7 @@ const RESIZE_REDRAW_DEBOUNCE_MS: u64 = 75; include!("chat/external_review.rs"); include!("chat/external_sources.rs"); +include!("chat/external_hooks.rs"); fn agent_event_stream_failure(error: TryRecvError) -> Option { match error { @@ -212,6 +218,14 @@ pub(crate) struct ChatMode { external_agent_notice_key: Option, external_agent_review_snapshot: Option, external_agent_mutation_rx: Option>, + external_hook_catalog_rx: Option< + Receiver< + std::result::Result< + ExternalHookCatalogSnapshotV1, + bitfun_core::external_sources::ExternalSourceOperationError, + >, + >, + >, } /// Map agent_type to a display name for status messages @@ -260,6 +274,7 @@ impl ChatMode { external_agent_notice_key: None, external_agent_review_snapshot: None, external_agent_mutation_rx: None, + external_hook_catalog_rx: None, } } diff --git a/src/apps/cli/src/modes/chat/commands.rs b/src/apps/cli/src/modes/chat/commands.rs index 8f08111606..5438b34174 100644 --- a/src/apps/cli/src/modes/chat/commands.rs +++ b/src/apps/cli/src/modes/chat/commands.rs @@ -312,6 +312,10 @@ impl ChatMode { builtin_reconfirmation_required, ) { CommandRoute::Builtin => { + if builtin_hook_help_requested(command_name, arguments) { + chat_state.add_system_message(external_hook_help_text()); + return Ok(None); + } let action = builtin_action.expect("route requires an available built-in action"); self.dispatch_action( action, @@ -707,6 +711,9 @@ impl ChatMode { ActionHandler::Extensions => { self.handle_external_control("", chat_view, chat_state, rt_handle); } + ActionHandler::Hooks => { + self.handle_external_hooks(chat_view, chat_state, rt_handle); + } ActionHandler::AcpHelp => { chat_state.add_system_message(crate::acp_cli::acp_help_text("bitfun")); chat_view.set_status(Some( @@ -875,6 +882,9 @@ impl ChatMode { fn action_opens_extension_management(action: &ActionSpec) -> bool { matches!( action.handler, - ActionHandler::Tools | ActionHandler::Extensions | ActionHandler::OpenAgentSelector + ActionHandler::Tools + | ActionHandler::Extensions + | ActionHandler::Hooks + | ActionHandler::OpenAgentSelector ) } diff --git a/src/apps/cli/src/modes/chat/external_hooks.rs b/src/apps/cli/src/modes/chat/external_hooks.rs new file mode 100644 index 0000000000..e4c617dab2 --- /dev/null +++ b/src/apps/cli/src/modes/chat/external_hooks.rs @@ -0,0 +1,339 @@ +const MAX_TUI_HOOK_SOURCES_PER_PROVIDER: usize = 10; +const MAX_TUI_HOOK_ENTRIES_PER_PROVIDER: usize = 100; +const MAX_TUI_HOOK_DIAGNOSTICS_PER_PROVIDER: usize = 20; +const MAX_TUI_HOOK_CATALOG_DIAGNOSTICS: usize = 20; + +fn external_hook_help_text() -> String { + [ + "Hooks", + "", + "Usage: /hooks", + "", + "Shows a read-only static catalog of Hooks configured for OpenCode, Claude Code, and Codex.", + "BitFun does not load or run handlers from this view. Coverage mapped means BitFun recognizes an equivalent reviewed Hook point; it does not mean the native handler is active.", + "", + "Help: /help hooks, /hooks -h, or /hooks --help", + ] + .join("\n") +} + +fn builtin_hook_help_requested(command_name: &str, arguments: &str) -> bool { + (command_name.eq_ignore_ascii_case("help") && arguments.trim().eq_ignore_ascii_case("hooks")) + || (command_name.eq_ignore_ascii_case("hooks") && !arguments.trim().is_empty()) +} + +fn render_external_hook_catalog(snapshot: &ExternalHookCatalogSnapshotV1) -> String { + let mut lines = vec![ + "Hooks (read-only)".to_string(), + "Static configuration only; no handler was loaded or executed.".to_string(), + String::new(), + ]; + if snapshot.discovery_pending { + lines.push("Hook discovery is still pending. Run /hooks again.".to_string()); + return lines.join("\n"); + } + if snapshot.sources.is_empty() + && snapshot.failed_provider_ids.is_empty() + && snapshot.stale_provider_ids.is_empty() + { + lines.push("No supported Hook configuration was found.".to_string()); + } + if !snapshot.providers.is_empty() { + let source_by_key = snapshot + .sources + .iter() + .map(|source| (&source.key, source)) + .collect::>(); + for provider in &snapshot.providers { + let provider_sources = snapshot + .sources + .iter() + .filter(|source| source.key.provider_id == provider.provider_id) + .collect::>(); + let provider_entry_count = snapshot + .entries + .iter() + .filter(|entry| entry.source.provider_id == provider.provider_id) + .count(); + let stale = snapshot + .stale_provider_ids + .iter() + .any(|provider_id| provider_id == &provider.provider_id); + let failed = snapshot + .failed_provider_ids + .iter() + .any(|provider_id| provider_id == &provider.provider_id); + lines.push(format!( + "{}: {} Hook{}, {} source{}{}", + provider.display_name, + provider_entry_count, + plural(provider_entry_count), + provider_sources.len(), + plural(provider_sources.len()), + if failed { + " (discovery failed)" + } else if stale { + " (stale)" + } else { + "" + }, + )); + if provider_sources.is_empty() { + lines.push(if failed { + " No valid catalog is available because static discovery failed.".to_string() + } else if stale { + " The last valid catalog is empty; the latest refresh failed.".to_string() + } else { + " No supported static Hook source was found.".to_string() + }); + continue; + } + let mut rendered_entries = 0; + let mut rendered_diagnostics = 0; + for source in provider_sources + .iter() + .take(MAX_TUI_HOOK_SOURCES_PER_PROVIDER) + { + lines.push(format!( + " {} [{}; {}; {}]", + source.display_name, + source_scope_label(source.scope), + source_health_label(source.health), + source.location_hint, + )); + for entry in snapshot + .entries + .iter() + .filter(|entry| entry.source == source.key) + .take(MAX_TUI_HOOK_ENTRIES_PER_PROVIDER - rendered_entries) + { + lines.push(format!( + " - {} [{}; {}; {}; matcher: {}]", + entry.native_event, + hook_handler_label(entry.handler_kind), + projection_label(entry), + native_activation_label(entry.native_activation), + matcher_label(&entry.matcher), + )); + rendered_entries += 1; + } + for diagnostic in source + .diagnostics + .iter() + .take(MAX_TUI_HOOK_DIAGNOSTICS_PER_PROVIDER - rendered_diagnostics) + { + lines.push(format!(" ! {}: {}", diagnostic.code, diagnostic.message)); + rendered_diagnostics += 1; + } + } + let omitted_sources = provider_sources + .len() + .saturating_sub(MAX_TUI_HOOK_SOURCES_PER_PROVIDER); + let omitted_entries = provider_entry_count.saturating_sub(rendered_entries); + let provider_diagnostic_count = provider_sources + .iter() + .map(|source| source.diagnostics.len()) + .sum::(); + let omitted_diagnostics = + provider_diagnostic_count.saturating_sub(rendered_diagnostics); + if omitted_sources + omitted_entries + omitted_diagnostics > 0 { + lines.push(format!( + " … omitted {omitted_sources} source(s), {omitted_entries} Hook(s), and {omitted_diagnostics} diagnostic(s); use Desktop settings for the full catalog." + )); + } + } + for entry in snapshot + .entries + .iter() + .filter(|entry| !source_by_key.contains_key(&entry.source)) + .take(MAX_TUI_HOOK_ENTRIES_PER_PROVIDER) + { + lines.push(format!("External: {}", entry.native_event)); + } + } + let catalog_diagnostics = snapshot + .diagnostics + .iter() + .filter(|diagnostic| diagnostic.source.is_none()) + .collect::>(); + if !catalog_diagnostics.is_empty() { + lines.push(String::new()); + lines.push("Catalog diagnostics:".to_string()); + for diagnostic in catalog_diagnostics + .iter() + .take(MAX_TUI_HOOK_CATALOG_DIAGNOSTICS) + { + lines.push(format!(" - {}: {}", diagnostic.code, diagnostic.message)); + } + if catalog_diagnostics.len() > MAX_TUI_HOOK_CATALOG_DIAGNOSTICS { + lines.push(format!( + " … {} additional catalog diagnostic(s) omitted.", + catalog_diagnostics.len() - MAX_TUI_HOOK_CATALOG_DIAGNOSTICS + )); + } + } + lines.push(String::new()); + lines.push( + "Edit Hooks in the source application's configuration. Help: /help hooks, /hooks -h, or /hooks --help" + .to_string(), + ); + lines.join("\n") +} + +fn matcher_label(matcher: &ExternalHookMatcherSummary) -> String { + match matcher { + ExternalHookMatcherSummary::Any => "all".to_string(), + ExternalHookMatcherSummary::Pattern { display } => display.clone(), + ExternalHookMatcherSummary::Dynamic => "dynamic".to_string(), + ExternalHookMatcherSummary::Unavailable => "unavailable".to_string(), + _ => "unknown".to_string(), + } +} + +fn projection_label(entry: &bitfun_core::external_hooks::ExternalHookCatalogEntry) -> &'static str { + match entry.projection_status { + ExternalHookProjectionStatus::Mapped => match entry + .mapping + .as_ref() + .map(|mapping| mapping.hook_point) + { + Some( + bitfun_product_domains::external_hook_contributions::ExternalHookPoint::ToolBefore, + ) => "coverage mapped: BitFun tool before", + Some( + bitfun_product_domains::external_hook_contributions::ExternalHookPoint::ToolAfter, + ) => "coverage mapped: BitFun tool after", + None => "invalid mapping", + }, + ExternalHookProjectionStatus::NativeOnly => "native only", + ExternalHookProjectionStatus::Opaque => "opaque static registration", + _ => "unknown projection", + } +} + +fn native_activation_label(activation: ExternalHookNativeActivation) -> &'static str { + match activation { + ExternalHookNativeActivation::Disabled => "native disabled", + ExternalHookNativeActivation::Unsupported => "unsupported by native runtime", + ExternalHookNativeActivation::Unknown => "native activation unknown", + _ => "native activation unknown", + } +} + +fn source_scope_label(scope: ExternalSourceScope) -> &'static str { + match scope { + ExternalSourceScope::UserGlobal => "user", + ExternalSourceScope::WorkspaceLocal => "workspace", + ExternalSourceScope::Project => "project", + _ => "external", + } +} + +fn source_health_label(health: ExternalSourceHealth) -> &'static str { + match health { + ExternalSourceHealth::Available => "available", + ExternalSourceHealth::Partial => "partial", + ExternalSourceHealth::Degraded => "degraded", + ExternalSourceHealth::Unavailable => "unavailable", + _ => "unknown", + } +} + +fn hook_handler_label(kind: bitfun_core::external_hooks::ExternalHookHandlerKind) -> &'static str { + use bitfun_core::external_hooks::ExternalHookHandlerKind; + match kind { + ExternalHookHandlerKind::Function => "function", + ExternalHookHandlerKind::Command => "command", + ExternalHookHandlerKind::Http => "http", + ExternalHookHandlerKind::McpTool => "mcp_tool", + ExternalHookHandlerKind::Prompt => "prompt", + ExternalHookHandlerKind::Agent => "agent", + _ => "unknown", + } +} + +fn plural(count: usize) -> &'static str { + if count == 1 { + "" + } else { + "s" + } +} + +impl ChatMode { + fn handle_external_hooks( + &mut self, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + if self.external_hook_catalog_rx.is_some() { + chat_view.set_status(Some("Hook catalog refresh already in progress".to_string())); + return; + } + let workspace_root = self.workspace_path_for_sync(chat_state); + let (sender, receiver) = mpsc::channel(); + rt_handle.spawn(async move { + let mut force_refresh = true; + let result = loop { + let result = local_external_hook_catalog_snapshot( + Some(workspace_root.as_path()), + force_refresh, + ) + .await; + if !matches!(&result, Ok(snapshot) if snapshot.discovery_pending) { + break result; + } + force_refresh = false; + tokio::time::sleep(Duration::from_millis(250)).await; + }; + let _ = sender.send(result); + }); + self.external_hook_catalog_rx = Some(receiver); + chat_view.set_status(Some("Refreshing Hook catalog...".to_string())); + } + + fn poll_external_hook_catalog( + &mut self, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + ) -> bool { + let result = match self + .external_hook_catalog_rx + .as_ref() + .map(Receiver::try_recv) + { + Some(Ok(result)) => result, + Some(Err(MpscTryRecvError::Empty)) | None => return false, + Some(Err(MpscTryRecvError::Disconnected)) => { + self.external_hook_catalog_rx = None; + chat_view.set_status(Some("Hook catalog refresh failed".to_string())); + chat_state.add_system_message( + "Hooks are unavailable because the background refresh ended unexpectedly." + .to_string(), + ); + return true; + } + }; + self.external_hook_catalog_rx = None; + match result { + Ok(snapshot) => { + chat_state.add_system_message(render_external_hook_catalog(&snapshot)); + chat_view.set_status(Some(format!( + "Hook catalog: {} sources, {} Hooks", + snapshot.sources.len(), + snapshot.entries.len(), + ))); + } + Err(error) => { + chat_state.add_system_message(format!( + "Hooks are unavailable ({}): {}", + error.code.as_str(), + error.detail, + )); + chat_view.set_status(Some("Hook catalog unavailable".to_string())); + } + } + true + } +} diff --git a/src/apps/cli/src/modes/chat/external_review.rs b/src/apps/cli/src/modes/chat/external_review.rs index 274a7fa5dd..96be9356b6 100644 --- a/src/apps/cli/src/modes/chat/external_review.rs +++ b/src/apps/cli/src/modes/chat/external_review.rs @@ -23,7 +23,7 @@ fn external_command_projections( let mut projections = snapshot .commands .iter() - .map(|entry| { + .filter_map(|entry| { let ecosystem = snapshot .sources .iter() @@ -70,7 +70,7 @@ fn external_command_projections( conflict_key, }) }); - ExternalCommandProjection { + Some(ExternalCommandProjection { action_id: format!("external-command:{}", entry.definition.name), command_name: entry.definition.name.clone(), invocation_alias: format!("/{}", entry.definition.name), @@ -80,7 +80,7 @@ fn external_command_projections( restricted, provider_conflict_key: None, native_collision, - } + }) }) .collect::>(); diff --git a/src/apps/cli/src/modes/chat/run.rs b/src/apps/cli/src/modes/chat/run.rs index 68a013d6a7..c060bc4d4f 100644 --- a/src/apps/cli/src/modes/chat/run.rs +++ b/src/apps/cli/src/modes/chat/run.rs @@ -276,6 +276,9 @@ impl ChatMode { if self.poll_external_control_mutation(&mut chat_view) { needs_redraw = true; } + if self.poll_external_hook_catalog(&mut chat_view, &mut chat_state) { + needs_redraw = true; + } if let Some(receiver) = permission_rx.as_mut() { for _ in 0..4 { diff --git a/src/apps/cli/src/modes/chat/tests.rs b/src/apps/cli/src/modes/chat/tests.rs index 1cb28855d0..82e3e1cef9 100644 --- a/src/apps/cli/src/modes/chat/tests.rs +++ b/src/apps/cli/src/modes/chat/tests.rs @@ -4,25 +4,27 @@ mod tests { use super::{ action_opens_extension_management, agent_event_stream_failure, apply_agent_mode_feedback, - apply_model_selection_feedback, builtin_command_reconfirmation, command_route, - external_agent_attention, external_agent_diagnostic_lines, - external_agent_pending_notice_key, external_agent_result_is_stale, - external_agent_review_text, external_command_projections, external_control_review_text, - external_integration_policy_lines, external_operation_error_status, - external_tool_mutation_result_label, external_tool_pending_notice_key, - external_tool_result_is_stale, external_tool_review_text, external_tool_run_location_label, - mark_active_turn_failed, merge_external_agent_mutation_snapshot, - mode_change_blocks_typed_submission, mode_change_completion_should_exit, - native_command_conflict_key, parse_command_token, parse_external_agent_review_action, - parse_external_control_action, parse_external_tool_review_action, - previous_session_mode_change_status, CommandQualifier, CommandRoute, - ExternalAgentReviewAction, ExternalControlUiAction, ExternalSourceConflictPreferences, - ExternalToolReviewAction, ModeSelectionApplyOutcome, ModelSelectionApplyOutcome, + apply_model_selection_feedback, builtin_command_reconfirmation, + builtin_hook_help_requested, command_route, external_agent_attention, + external_agent_diagnostic_lines, external_agent_pending_notice_key, + external_agent_result_is_stale, external_agent_review_text, external_command_projections, + external_control_review_text, external_hook_help_text, external_integration_policy_lines, + external_operation_error_status, external_tool_mutation_result_label, + external_tool_pending_notice_key, external_tool_result_is_stale, external_tool_review_text, + external_tool_run_location_label, mark_active_turn_failed, + merge_external_agent_mutation_snapshot, mode_change_blocks_typed_submission, + mode_change_completion_should_exit, native_command_conflict_key, parse_command_token, + parse_external_agent_review_action, parse_external_control_action, + parse_external_tool_review_action, previous_session_mode_change_status, + render_external_hook_catalog, CommandQualifier, CommandRoute, ExternalAgentReviewAction, + ExternalControlUiAction, ExternalSourceConflictPreferences, ExternalToolReviewAction, + ModeSelectionApplyOutcome, ModelSelectionApplyOutcome, }; use crate::actions::{action_conflict_behavior_version, ActionState, ResolvedKeymap}; use crate::chat_state::ChatState; use crate::config::ShortcutsConfig; use crate::ui::command_menu::{ExternalCommandProjection, NativeCommandCollisionProjection}; + use bitfun_core::external_hooks::ExternalHookCatalogSnapshotV1; use bitfun_core::external_sources::{ ExternalSourceAssetKind, ExternalSourceCatalogSnapshot, ExternalSourceControlSnapshotV1, ExternalSourceDiagnostic, ExternalSourceDiagnosticSeverity, ExternalSourceOperationError, @@ -671,6 +673,246 @@ mod tests { ); } + #[test] + fn hooks_uses_the_existing_native_command_collision_flow() { + let action = + crate::actions::action_for_alias("/hooks", crate::actions::ActionContext::Chat) + .expect("/hooks must be registered"); + assert_eq!(action.id, "hooks"); + let collision = external_command("hooks", None); + assert_eq!( + command_route( + CommandQualifier::Unqualified, + true, + Some(&collision), + false, + false, + ), + CommandRoute::AskForCollisionChoice + ); + assert_eq!( + command_route( + CommandQualifier::External, + true, + Some(&collision), + false, + false, + ), + CommandRoute::External + ); + let selected_external = external_command("hooks", Some("external:hooks")); + assert_eq!( + command_route( + CommandQualifier::Unqualified, + true, + Some(&selected_external), + false, + false, + ), + CommandRoute::External + ); + assert!(builtin_hook_help_requested("hooks", "unexpected")); + assert!(builtin_hook_help_requested("help", "hooks")); + assert!(!builtin_hook_help_requested("help", "other")); + } + + #[test] + fn selected_external_help_keeps_its_hooks_argument() { + let selected_external = external_command("help", Some("external:help")); + assert_eq!( + command_route( + CommandQualifier::Unqualified, + true, + Some(&selected_external), + false, + false, + ), + CommandRoute::External + ); + } + + #[test] + fn hook_catalog_text_is_read_only_redacted_and_explains_native_only_events() { + let snapshot: ExternalHookCatalogSnapshotV1 = serde_json::from_value(serde_json::json!({ + "schemaVersion": 1, + "discoveryPending": false, + "providers": [{ + "providerId": "claude-code.hooks", + "ecosystemId": "claude-code", + "displayName": "Claude Code Hooks" + }], + "sources": [{ + "key": {"providerId": "claude-code.hooks", "sourceId": "project-settings"}, + "ecosystemId": "claude-code", + "displayName": "Claude Code project settings", + "sourceKind": "settings", + "scope": "project", + "locationHint": ".claude/settings.json", + "health": "available", + "contentVersion": "sha256:source" + }], + "entries": [ + { + "stableKey": "claude-pre", + "source": {"providerId": "claude-code.hooks", "sourceId": "project-settings"}, + "nativeEvent": "PreToolUse", + "matcher": {"kind": "pattern", "display": "Bash|Edit"}, + "handlerKind": "command", + "projectionStatus": "mapped", + "nativeActivation": "unknown", + "mapping": {"hookPoint": "tool_before"}, + "contentVersion": "sha256:pre" + }, + { + "stableKey": "claude-session", + "source": {"providerId": "claude-code.hooks", "sourceId": "project-settings"}, + "nativeEvent": "SessionStart", + "matcher": {"kind": "any"}, + "handlerKind": "http", + "projectionStatus": "native_only", + "nativeActivation": "unknown", + "contentVersion": "sha256:session" + }, + { + "stableKey": "claude-opaque", + "source": {"providerId": "claude-code.hooks", "sourceId": "project-settings"}, + "nativeEvent": "", + "matcher": {"kind": "dynamic"}, + "handlerKind": "function", + "projectionStatus": "opaque", + "nativeActivation": "unknown", + "contentVersion": "sha256:opaque" + } + ], + "staleProviderIds": [], + "diagnostics": [] + })) + .unwrap(); + + let text = render_external_hook_catalog(&snapshot); + assert!(text.contains("Hooks (read-only)")); + assert!(text.contains("Claude Code")); + assert!(text.contains("PreToolUse")); + assert!(text.contains("coverage mapped: BitFun tool before")); + assert!(text.contains("SessionStart")); + assert!(text.contains("native only")); + assert!(text.contains("opaque static registration")); + assert!(text.contains("Bash|Edit")); + assert!(!text.contains("command body")); + } + + #[test] + fn hooks_help_uses_the_established_slash_help_pattern() { + let help = external_hook_help_text(); + assert!(help.contains("Usage: /hooks")); + assert!(help.contains("/help hooks")); + assert!(help.contains("/hooks -h")); + assert!(help.contains("/hooks --help")); + assert!(!help.contains("/builtin:hooks")); + } + + #[test] + fn hook_catalog_text_distinguishes_failed_empty_providers() { + let snapshot: ExternalHookCatalogSnapshotV1 = serde_json::from_value(serde_json::json!({ + "schemaVersion": 1, + "discoveryPending": false, + "providers": [{ + "providerId": "failed.hooks", + "ecosystemId": "failed", + "displayName": "Failed Hooks" + }], + "sources": [], + "entries": [], + "failedProviderIds": ["failed.hooks"], + "diagnostics": [{ + "severity": "error", + "assetKind": "hook", + "code": "failed.hook.read_failed", + "message": "read failed" + }] + })) + .unwrap(); + + let text = render_external_hook_catalog(&snapshot); + + assert!(text.contains("Failed Hooks: 0 Hooks, 0 sources (discovery failed)")); + assert!(text.contains("No valid catalog is available")); + assert!(!text.contains("No supported Hook configuration was found")); + } + + #[test] + fn hook_catalog_text_does_not_call_a_stale_empty_catalog_successful() { + let snapshot: ExternalHookCatalogSnapshotV1 = serde_json::from_value(serde_json::json!({ + "schemaVersion": 1, + "discoveryPending": false, + "providers": [{ + "providerId": "stale.hooks", + "ecosystemId": "stale", + "displayName": "Stale Hooks" + }], + "sources": [], + "entries": [], + "staleProviderIds": ["stale.hooks"], + "diagnostics": [] + })) + .unwrap(); + + let text = render_external_hook_catalog(&snapshot); + + assert!(text.contains("The last valid catalog is empty")); + assert!(!text.contains("No supported Hook configuration was found")); + } + + #[test] + fn hook_catalog_text_bounds_large_provider_output() { + let mut snapshot: ExternalHookCatalogSnapshotV1 = + serde_json::from_value(serde_json::json!({ + "schemaVersion": 1, + "discoveryPending": false, + "providers": [{ + "providerId": "test.hooks", + "ecosystemId": "test", + "displayName": "Test Hooks" + }], + "sources": [{ + "key": {"providerId": "test.hooks", "sourceId": "project"}, + "ecosystemId": "test", + "displayName": "Project Hooks", + "sourceKind": "settings", + "scope": "project", + "locationHint": ".test/settings.json", + "health": "available", + "contentVersion": "source-v1" + }], + "entries": [], + "staleProviderIds": [], + "diagnostics": [] + })) + .unwrap(); + snapshot.entries = (0..105) + .map( + |index| bitfun_core::external_hooks::ExternalHookCatalogEntry { + stable_key: format!("test-{index}"), + source: snapshot.sources[0].key.clone(), + native_event: format!("Event{index}"), + matcher: bitfun_core::external_hooks::ExternalHookMatcherSummary::Any, + handler_kind: bitfun_core::external_hooks::ExternalHookHandlerKind::Command, + projection_status: + bitfun_core::external_hooks::ExternalHookProjectionStatus::NativeOnly, + native_activation: + bitfun_core::external_hooks::ExternalHookNativeActivation::Unknown, + mapping: None, + content_version: format!("entry-v{index}"), + }, + ) + .collect(); + + let text = render_external_hook_catalog(&snapshot); + + assert_eq!(text.matches(" - Event").count(), 100); + assert!(text.contains("omitted 0 source(s), 5 Hook(s)")); + } + #[test] fn unresolved_provider_conflicts_expose_explicit_cli_choices() { let snapshot: ExternalSourceCatalogSnapshot = serde_json::from_value(serde_json::json!({ diff --git a/src/apps/cli/src/ui/command_palette.rs b/src/apps/cli/src/ui/command_palette.rs index f34f93e5db..e01796ec15 100644 --- a/src/apps/cli/src/ui/command_palette.rs +++ b/src/apps/cli/src/ui/command_palette.rs @@ -52,6 +52,7 @@ const DEFAULT_ITEM_ORDER: &[&str] = &[ "tools", "mcp_servers", "extensions", + "hooks", "login", "logout", "help", diff --git a/src/apps/cli/src/ui/startup.rs b/src/apps/cli/src/ui/startup.rs index 6f7bed43a1..cedb616b24 100644 --- a/src/apps/cli/src/ui/startup.rs +++ b/src/apps/cli/src/ui/startup.rs @@ -1043,6 +1043,7 @@ impl StartupPage { | ActionHandler::ReloadSkills | ActionHandler::Tools | ActionHandler::Extensions + | ActionHandler::Hooks | ActionHandler::History | ActionHandler::ToggleAutoApprove | ActionHandler::Interrupt diff --git a/src/apps/desktop/src/api/external_hooks_api.rs b/src/apps/desktop/src/api/external_hooks_api.rs new file mode 100644 index 0000000000..28c938857f --- /dev/null +++ b/src/apps/desktop/src/api/external_hooks_api.rs @@ -0,0 +1,55 @@ +//! Desktop transport for the runtime-free external Hook catalog. + +use bitfun_core::external_hooks::{ + local_external_hook_catalog_snapshot, ExternalHookCatalogSnapshotV1, +}; +use bitfun_core::external_sources::ExternalSourceOperationResult; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalHookCatalogRequest { + pub workspace_path: Option, + #[serde(default)] + pub force_refresh: bool, +} + +pub type ExternalHookCatalogResponse = ExternalHookCatalogSnapshotV1; + +#[tauri::command] +pub async fn get_external_hook_catalog( + request: ExternalHookCatalogRequest, +) -> ExternalSourceOperationResult { + let workspace = + super::external_sources_api::require_local_workspace(request.workspace_path.as_deref()) + .await?; + local_external_hook_catalog_snapshot(workspace, request.force_refresh).await +} + +#[cfg(test)] +mod tests { + use super::ExternalHookCatalogRequest; + + #[test] + fn request_uses_the_structured_camel_case_desktop_contract() { + let request: ExternalHookCatalogRequest = serde_json::from_value(serde_json::json!({ + "workspacePath": "D:/workspace/project", + "forceRefresh": true + })) + .unwrap(); + assert_eq!( + request.workspace_path.as_deref(), + Some("D:/workspace/project") + ); + assert!(request.force_refresh); + + assert!( + serde_json::from_value::(serde_json::json!({ + "workspacePath": "D:/workspace/project", + "forceRefresh": true, + "unexpected": true + })) + .is_err() + ); + } +} diff --git a/src/apps/desktop/src/api/external_sources_api.rs b/src/apps/desktop/src/api/external_sources_api.rs index 2407971ba1..49f4efd2b3 100644 --- a/src/apps/desktop/src/api/external_sources_api.rs +++ b/src/apps/desktop/src/api/external_sources_api.rs @@ -132,7 +132,7 @@ pub struct ChooseExternalMcpConflictRequest { pub type ExternalSourceSnapshotResponse = ExternalSourcePublicSnapshot; pub type ExternalSourceControlResponse = ExternalSourceSurfaceSnapshotV1; -async fn require_local_workspace( +pub(super) async fn require_local_workspace( workspace_path: Option<&str>, ) -> ExternalSourceOperationResult> { let Some(workspace_path) = workspace_path else { diff --git a/src/apps/desktop/src/api/mod.rs b/src/apps/desktop/src/api/mod.rs index 32468853df..c1895e84e4 100644 --- a/src/apps/desktop/src/api/mod.rs +++ b/src/apps/desktop/src/api/mod.rs @@ -19,6 +19,7 @@ pub mod debug_api; pub mod diff_api; pub mod dto; pub mod editor_ai_api; +pub mod external_hooks_api; pub mod external_sources_api; pub mod git_agent_api; pub mod git_api; diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index 0471ffb70b..47fe521e52 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -458,6 +458,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "get_directory_children_paginated", RemoteWorkspacePolicy::LegacyUnaudited, ), + ( + "get_external_hook_catalog", + RemoteWorkspacePolicy::RemoteUnsupported, + ), ( "get_external_source_snapshot", RemoteWorkspacePolicy::RemoteUnsupported, diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 087044fcbd..0eb894c843 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -44,6 +44,7 @@ use api::custom_agent_api::{ update_custom_agent, }; use api::diff_api::*; +use api::external_hooks_api::*; use api::external_sources_api::*; use api::git_agent_api::*; use api::git_api::*; @@ -966,6 +967,7 @@ pub async fn run() { api::btw_api::btw_cancel, api::editor_ai_api::editor_ai_stream, api::editor_ai_api::editor_ai_cancel, + get_external_hook_catalog, get_external_source_snapshot, reveal_external_source_location, get_external_source_control_snapshot, diff --git a/src/crates/adapters/AGENTS.md b/src/crates/adapters/AGENTS.md index cf5b125ef2..03ab9333ac 100644 --- a/src/crates/adapters/AGENTS.md +++ b/src/crates/adapters/AGENTS.md @@ -12,7 +12,10 @@ services. | Crate | Responsibility | Local doc | |---|---|---| | `ai-adapters` | AI provider request/response adapters and stream protocol glue | [AGENTS.md](ai-adapters/AGENTS.md) | -| `opencode-adapter` | OpenCode source semantics for the live Command, standalone Tool, and Subagent providers; managed-package static preview | [AGENTS.md](opencode-adapter/AGENTS.md) | +| `opencode-adapter` | OpenCode source semantics for the live Command, standalone Tool, Subagent, MCP, and static Hook providers; managed-package static preview | [AGENTS.md](opencode-adapter/AGENTS.md) | +| `claude-code-adapter` | Runtime-free Claude Code Hook source discovery and redacted projection | [AGENTS.md](claude-code-adapter/AGENTS.md) | +| `codex-adapter` | Runtime-free Codex Hook source discovery and redacted projection | [AGENTS.md](codex-adapter/AGENTS.md) | +| `static-hook-support` | Shared bounded JSON/TOML Hook parser used by sibling ecosystem adapters; no ecosystem policy or runtime | inherited | | `transport` | Event transport emitters and host transport adapters | [AGENTS.md](transport/AGENTS.md) | | `webdriver` | Embedded WebDriver protocol and browser automation adapter | [AGENTS.md](webdriver/AGENTS.md) | diff --git a/src/crates/adapters/claude-code-adapter/AGENTS.md b/src/crates/adapters/claude-code-adapter/AGENTS.md new file mode 100644 index 0000000000..d9f16fcf6b --- /dev/null +++ b/src/crates/adapters/claude-code-adapter/AGENTS.md @@ -0,0 +1,6 @@ +# Claude Code Adapter Instructions + +- This crate may read and normalize Claude Code configuration but must never invoke Hook handlers, start Claude Code, connect MCP servers, or own product policy. +- Keep handler bodies, commands, prompts, URLs, arguments, environment values, and credentials inside the adapter. Public contracts expose only bounded summaries. +- Preserve native Claude Code event and handler names. Map only reviewed BitFun Hook points; report every other event as native-only. +- Do not depend on another ecosystem adapter. Shared product behavior belongs in contracts or assembly. diff --git a/src/crates/adapters/claude-code-adapter/Cargo.toml b/src/crates/adapters/claude-code-adapter/Cargo.toml new file mode 100644 index 0000000000..0172e46b26 --- /dev/null +++ b/src/crates/adapters/claude-code-adapter/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "bitfun-claude-code-adapter" +version.workspace = true +authors.workspace = true +edition.workspace = true +description = "Claude Code static source adapter for BitFun" + +[lib] +name = "bitfun_claude_code_adapter" +crate-type = ["rlib"] + +[dependencies] +bitfun-product-domains = { path = "../../contracts/product-domains", default-features = false, features = ["external-sources"] } +bitfun-static-hook-support = { path = "../static-hook-support" } +dirs = { workspace = true } +hex = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } + +[lints] +workspace = true diff --git a/src/crates/adapters/claude-code-adapter/src/hook_source.rs b/src/crates/adapters/claude-code-adapter/src/hook_source.rs new file mode 100644 index 0000000000..18945b14f5 --- /dev/null +++ b/src/crates/adapters/claude-code-adapter/src/hook_source.rs @@ -0,0 +1,451 @@ +use bitfun_product_domains::external_hook_catalog::{ + ExternalHookCatalogEntry, ExternalHookHandlerKind, ExternalHookMapping, + ExternalHookMatcherSummary, ExternalHookNativeActivation, ExternalHookProjectionStatus, + ExternalHookProviderIdentity, ExternalHookProviderSnapshot, ExternalHookSource, + ExternalHookSourceKind, ExternalHookSourceProvider, +}; +use bitfun_product_domains::external_hook_contributions::ExternalHookPoint; +use bitfun_product_domains::external_sources::{ + EcosystemId, ExternalSourceAssetKind, ExternalSourceContext, ExternalSourceDiagnostic, + ExternalSourceHealth, ExternalSourceProviderError, ExternalSourceScope, SourceKey, +}; +use bitfun_static_hook_support::{ + bounded_project_ancestors, parse_hook_document, read_bounded_file, + redacted_parse_content_version, regular_file_exists, BoundedFileRead, StaticHookDocumentFormat, + StaticHookHandlerRule, StaticHookParseIssue, +}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::path::{Path, PathBuf}; + +const PROVIDER_ID: &str = "claude-code.hooks"; +const ECOSYSTEM_ID: &str = "claude-code"; +const MAX_SETTINGS_FILE_BYTES: usize = 1024 * 1024; +const MAX_HANDLERS: usize = 2048; +const MAX_PROJECT_ANCESTORS: usize = 32; +const HANDLER_RULES: &[StaticHookHandlerRule] = &[ + StaticHookHandlerRule::new("command", ExternalHookHandlerKind::Command, &["command"]), + StaticHookHandlerRule::new("http", ExternalHookHandlerKind::Http, &["url"]), + StaticHookHandlerRule::new( + "mcp_tool", + ExternalHookHandlerKind::McpTool, + &["server", "tool"], + ), + StaticHookHandlerRule::new("prompt", ExternalHookHandlerKind::Prompt, &["prompt"]), + StaticHookHandlerRule::new("agent", ExternalHookHandlerKind::Agent, &["prompt"]), +]; + +/// Reads Claude Code's static switch without applying settings precedence or +/// attempting to reproduce managed/runtime policy. +fn parse_disable_all_hooks(bytes: &[u8]) -> Result, ()> { + let Value::Object(root) = serde_json::from_slice::(bytes).map_err(|_| ())? else { + return Err(()); + }; + Ok(root.get("disableAllHooks").and_then(Value::as_bool)) +} + +#[derive(Debug, Clone)] +pub struct ClaudeCodeHookProviderOptions { + pub user_settings_file: PathBuf, + pub project_root_override: Option, + pub project_settings_enabled: bool, +} + +impl ClaudeCodeHookProviderOptions { + pub fn from_environment() -> Self { + let user_settings_file = dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".claude/settings.json"); + Self { + user_settings_file, + project_root_override: None, + project_settings_enabled: true, + } + } +} + +impl Default for ClaudeCodeHookProviderOptions { + fn default() -> Self { + Self::from_environment() + } +} + +pub struct ClaudeCodeHookProvider { + options: ClaudeCodeHookProviderOptions, +} + +impl ClaudeCodeHookProvider { + pub fn new(options: ClaudeCodeHookProviderOptions) -> Self { + Self { options } + } + + fn project_roots(&self, workspace_root: &Path) -> Vec { + let boundary = self + .options + .project_root_override + .as_deref() + .unwrap_or(workspace_root); + bounded_project_ancestors(workspace_root, boundary, MAX_PROJECT_ANCESTORS) + } + + fn layers(&self, context: &ExternalSourceContext) -> Vec { + let mut layers = vec![SettingsLayer { + path: self.options.user_settings_file.clone(), + source_id: "user-settings".to_string(), + label: "Claude Code user settings".to_string(), + location_hint: "~/.claude/settings.json".to_string(), + scope: ExternalSourceScope::UserGlobal, + }]; + if self.options.project_settings_enabled { + if let Some(workspace_root) = &context.workspace_root { + let boundary = self + .options + .project_root_override + .as_deref() + .unwrap_or(workspace_root); + for root in self.project_roots(workspace_root) { + let project_root = root.join(".claude"); + let relative = root.strip_prefix(boundary).unwrap_or(Path::new("")); + for (name, local) in [("settings.json", false), ("settings.local.json", true)] { + let path = project_root.join(name); + let relative_path = relative.join(".claude").join(name); + layers.push(SettingsLayer { + source_id: format!( + "project-{}", + short_hash(path.as_os_str().as_encoded_bytes()) + ), + path, + label: if local { + "Claude Code local project settings".to_string() + } else { + "Claude Code project settings".to_string() + }, + location_hint: relative_path.to_string_lossy().to_string(), + scope: if local { + ExternalSourceScope::WorkspaceLocal + } else { + ExternalSourceScope::Project + }, + }); + } + } + } + } + layers + } +} + +impl Default for ClaudeCodeHookProvider { + fn default() -> Self { + Self::new(ClaudeCodeHookProviderOptions::default()) + } +} + +impl ExternalHookSourceProvider for ClaudeCodeHookProvider { + fn identity(&self) -> ExternalHookProviderIdentity { + ExternalHookProviderIdentity::new(PROVIDER_ID, ECOSYSTEM_ID, "Claude Code Hooks") + .expect("static Claude Code Hook provider identity must be valid") + } + + fn discover( + &self, + context: &ExternalSourceContext, + ) -> Result { + if context + .workspace_root + .as_ref() + .is_some_and(|workspace_root| !workspace_root.is_absolute()) + { + return Err(ExternalSourceProviderError::new( + "claude.hook.workspace_invalid", + "workspace root must be absolute", + false, + )); + } + + let mut loaded_layers = Vec::new(); + let mut activation_reliable = true; + let mut effective_disabled = None; + for layer in self.layers(context) { + match regular_file_exists(&layer.path) { + Ok(false) => continue, + Ok(true) => {} + Err(error) => { + return Err(ExternalSourceProviderError::new( + "claude.hook.settings_metadata_failed", + format!("Claude Code Hook settings metadata is unavailable: {error}"), + true, + )); + } + } + let bytes = match read_bounded_file(&layer.path, MAX_SETTINGS_FILE_BYTES) { + Ok(BoundedFileRead::Content(bytes)) => Some(bytes), + Ok(BoundedFileRead::TooLarge) => { + activation_reliable = false; + None + } + Err(error) => { + return Err(ExternalSourceProviderError::new( + "claude.hook.settings_unreadable", + format!("Claude Code Hook settings could not be read: {error}"), + true, + )); + } + }; + if let Some(bytes) = &bytes { + match parse_disable_all_hooks(bytes) { + Ok(Some(disabled)) => effective_disabled = Some(disabled), + Ok(None) => {} + Err(()) => activation_reliable = false, + } + } + loaded_layers.push((layer, bytes)); + } + let native_activation = if activation_reliable && effective_disabled == Some(true) { + ExternalHookNativeActivation::Disabled + } else { + ExternalHookNativeActivation::Unknown + }; + + let mut sources = Vec::new(); + let mut entries = Vec::new(); + let mut diagnostics = Vec::new(); + let mut remaining_handlers = MAX_HANDLERS; + for (layer, bytes) in loaded_layers { + inspect_settings_layer( + &layer, + bytes.as_deref(), + native_activation, + &mut remaining_handlers, + &mut sources, + &mut entries, + &mut diagnostics, + )?; + } + diagnostics + .sort_by(|left, right| (&left.code, &left.message).cmp(&(&right.code, &right.message))); + let snapshot = ExternalHookProviderSnapshot { + provider: self.identity(), + sources, + entries, + diagnostics, + }; + snapshot.validate().map_err(|error| { + ExternalSourceProviderError::new( + "claude.hook.snapshot_invalid", + error.to_string(), + false, + ) + })?; + Ok(snapshot) + } +} + +struct SettingsLayer { + path: PathBuf, + source_id: String, + label: String, + location_hint: String, + scope: ExternalSourceScope, +} + +fn inspect_settings_layer( + layer: &SettingsLayer, + bytes: Option<&[u8]>, + native_activation: ExternalHookNativeActivation, + remaining_handlers: &mut usize, + sources: &mut Vec, + entries: &mut Vec, + diagnostics: &mut Vec, +) -> Result<(), ExternalSourceProviderError> { + let source_key = SourceKey::new(PROVIDER_ID, &layer.source_id) + .expect("static Claude Code Hook source identity must be valid"); + let bytes = match bytes { + Some(bytes) => bytes, + None => { + let diagnostic = hook_warning( + "claude.hook.settings_too_large", + "Claude Code settings exceed the 1 MiB static inspection limit", + Some(source_key.clone()), + ); + sources.push(source( + layer, + source_key, + ExternalSourceHealth::Unavailable, + "unavailable:too-large".to_string(), + vec![diagnostic.clone()], + )); + diagnostics.push(diagnostic); + return Ok(()); + } + }; + let parsed = parse_hook_document( + bytes, + StaticHookDocumentFormat::Json, + HANDLER_RULES, + *remaining_handlers, + ); + let version = redacted_parse_content_version(&parsed); + let mut source_diagnostics = Vec::new(); + if parsed.all_disabled { + source_diagnostics.push(hook_warning( + "claude.hook.all_disabled", + "Claude Code declares disableAllHooks for this settings layer; entries remain visible for inspection", + Some(source_key.clone()), + )); + } + *remaining_handlers = remaining_handlers.saturating_sub(parsed.inspected_handlers); + for issue in parsed.issues { + source_diagnostics.push(parse_issue_diagnostic(issue, &source_key)); + } + for handler in parsed.handlers { + entries.push(entry( + &source_key, + &handler.native_event, + handler.matcher, + handler.handler_kind, + handler.group_index, + handler.handler_index, + native_activation, + &version, + )); + } + let health = if source_diagnostics.is_empty() { + ExternalSourceHealth::Available + } else { + ExternalSourceHealth::Degraded + }; + diagnostics.extend(source_diagnostics.clone()); + sources.push(source( + layer, + source_key, + health, + version, + source_diagnostics, + )); + Ok(()) +} + +fn parse_issue_diagnostic( + issue: StaticHookParseIssue, + source: &SourceKey, +) -> ExternalSourceDiagnostic { + let (code, message) = match issue { + StaticHookParseIssue::DocumentInvalid => ( + "claude.hook.settings_parse_failed", + "Claude Code settings are not a valid JSON object", + ), + StaticHookParseIssue::EventNameInvalid => ( + "claude.hook.event_name_invalid", + "Claude Code Hook event name is invalid or exceeds the inspection limit", + ), + StaticHookParseIssue::EventInvalid => ( + "claude.hook.event_invalid", + "Claude Code Hook event must contain an array of matcher groups", + ), + StaticHookParseIssue::GroupInvalid => ( + "claude.hook.group_invalid", + "Claude Code Hook matcher group must contain a hooks array", + ), + StaticHookParseIssue::HandlerInvalid => ( + "claude.hook.handler_invalid", + "Claude Code Hook handler is missing a supported type or required field", + ), + StaticHookParseIssue::HandlerLimit => ( + "claude.hook.handler_limit", + "Additional Claude Code Hook handlers were omitted after the 2048 item inspection limit", + ), + }; + hook_warning(code, message, Some(source.clone())) +} + +#[allow(clippy::too_many_arguments)] +fn entry( + source: &SourceKey, + native_event: &str, + matcher: ExternalHookMatcherSummary, + handler_kind: ExternalHookHandlerKind, + group_index: usize, + handler_index: usize, + native_activation: ExternalHookNativeActivation, + source_version: &str, +) -> ExternalHookCatalogEntry { + let mapping = match native_event { + "PreToolUse" => Some(ExternalHookMapping { + hook_point: ExternalHookPoint::ToolBefore, + }), + "PostToolUse" => Some(ExternalHookMapping { + hook_point: ExternalHookPoint::ToolAfter, + }), + _ => None, + }; + ExternalHookCatalogEntry { + stable_key: format!( + "claude-hook:{}", + short_hash( + format!( + "{}:{native_event}:{group_index}:{handler_index}", + source.stable_key() + ) + .as_bytes() + ) + ), + source: source.clone(), + native_event: native_event.to_string(), + matcher, + handler_kind, + projection_status: if mapping.is_some() { + ExternalHookProjectionStatus::Mapped + } else { + ExternalHookProjectionStatus::NativeOnly + }, + native_activation, + mapping, + content_version: content_hash( + format!( + "{source_version}:{native_event}:{group_index}:{handler_index}:{}", + match native_activation { + ExternalHookNativeActivation::Disabled => "disabled", + ExternalHookNativeActivation::Unsupported => "unsupported", + ExternalHookNativeActivation::Unknown => "unknown", + _ => "unknown", + } + ) + .as_bytes(), + ), + } +} + +fn source( + layer: &SettingsLayer, + key: SourceKey, + health: ExternalSourceHealth, + content_version: String, + diagnostics: Vec, +) -> ExternalHookSource { + ExternalHookSource { + key, + ecosystem_id: EcosystemId::new(ECOSYSTEM_ID) + .expect("static Claude Code ecosystem id must be valid"), + display_name: layer.label.to_string(), + source_kind: ExternalHookSourceKind::Settings, + scope: layer.scope, + location_hint: layer.location_hint.to_string(), + health, + content_version, + diagnostics, + } +} + +fn hook_warning(code: &str, message: &str, source: Option) -> ExternalSourceDiagnostic { + ExternalSourceDiagnostic::warning(code, message, source) + .with_asset_kind(ExternalSourceAssetKind::Hook) +} + +fn content_hash(value: &[u8]) -> String { + format!("sha256:{}", hex::encode(Sha256::digest(value))) +} + +fn short_hash(value: &[u8]) -> String { + hex::encode(Sha256::digest(value))[..24].to_string() +} diff --git a/src/crates/adapters/claude-code-adapter/src/lib.rs b/src/crates/adapters/claude-code-adapter/src/lib.rs new file mode 100644 index 0000000000..6c7c0ff4bd --- /dev/null +++ b/src/crates/adapters/claude-code-adapter/src/lib.rs @@ -0,0 +1,5 @@ +//! Runtime-free Claude Code source adapter. + +mod hook_source; + +pub use hook_source::{ClaudeCodeHookProvider, ClaudeCodeHookProviderOptions}; diff --git a/src/crates/adapters/claude-code-adapter/tests/hook_source.rs b/src/crates/adapters/claude-code-adapter/tests/hook_source.rs new file mode 100644 index 0000000000..332459d305 --- /dev/null +++ b/src/crates/adapters/claude-code-adapter/tests/hook_source.rs @@ -0,0 +1,229 @@ +use bitfun_claude_code_adapter::{ClaudeCodeHookProvider, ClaudeCodeHookProviderOptions}; +use bitfun_product_domains::external_hook_catalog::{ + ExternalHookHandlerKind, ExternalHookNativeActivation, ExternalHookProjectionStatus, + ExternalHookSourceProvider, +}; +use bitfun_product_domains::external_hook_contributions::ExternalHookPoint; +use bitfun_product_domains::external_sources::{ExecutionDomainId, ExternalSourceContext}; +use std::fs; +use tempfile::tempdir; + +fn context(workspace: &std::path::Path) -> ExternalSourceContext { + ExternalSourceContext { + workspace_root: Some(workspace.to_path_buf()), + execution_domain_id: ExecutionDomainId::new("local-user").unwrap(), + } +} + +#[test] +fn discovers_user_project_and_local_settings_with_native_handler_kinds() { + let root = tempdir().unwrap(); + let user_settings = root.path().join("home/.claude/settings.json"); + let workspace = root.path().join("workspace"); + fs::create_dir_all(user_settings.parent().unwrap()).unwrap(); + fs::create_dir_all(workspace.join(".claude")).unwrap(); + fs::write( + &user_settings, + r#"{ + "hooks": { + "PreToolUse": [{"matcher":"Bash|Edit","hooks":[{"type":"command","command":"secret-command --token abc"}]}], + "SessionStart": [{"hooks":[{"type":"http","url":"https://secret.example/hook"}]}] + } + }"#, + ) + .unwrap(); + fs::write( + workspace.join(".claude/settings.json"), + r#"{"hooks":{"PostToolUse":[{"matcher":"mcp__.*","hooks":[{"type":"mcp_tool","server":"private","tool":"audit"}]}],"Stop":[{"hooks":[{"type":"prompt","prompt":"private prompt"}]}]}}"#, + ) + .unwrap(); + fs::write( + workspace.join(".claude/settings.local.json"), + r#"{"hooks":{"PermissionRequest":[{"hooks":[{"type":"agent","prompt":"private agent task"}]}]}}"#, + ) + .unwrap(); + + let provider = ClaudeCodeHookProvider::new(ClaudeCodeHookProviderOptions { + user_settings_file: user_settings, + project_root_override: Some(workspace.clone()), + project_settings_enabled: true, + }); + let snapshot = provider.discover(&context(&workspace)).unwrap(); + + assert_eq!(snapshot.sources.len(), 3); + assert_eq!(snapshot.entries.len(), 5); + assert!(snapshot.entries.iter().any(|entry| { + entry.handler_kind == ExternalHookHandlerKind::Command + && entry.mapping.as_ref().map(|mapping| mapping.hook_point) + == Some(ExternalHookPoint::ToolBefore) + })); + assert!(snapshot.entries.iter().any(|entry| { + entry.handler_kind == ExternalHookHandlerKind::McpTool + && entry.mapping.as_ref().map(|mapping| mapping.hook_point) + == Some(ExternalHookPoint::ToolAfter) + })); + assert!(snapshot.entries.iter().any(|entry| { + entry.native_event == "SessionStart" + && entry.projection_status == ExternalHookProjectionStatus::NativeOnly + })); + + let serialized = serde_json::to_string(&snapshot).unwrap(); + assert!(!serialized.contains("secret-command")); + assert!(!serialized.contains("secret.example")); + assert!(!serialized.contains("private prompt")); + assert!(!serialized.contains("private agent task")); +} + +#[test] +fn malformed_handlers_are_isolated_and_disable_all_hooks_is_visible() { + let root = tempdir().unwrap(); + let user_settings = root.path().join("settings.json"); + let workspace = root.path().join("workspace"); + fs::create_dir_all(&workspace).unwrap(); + fs::write( + &user_settings, + r#"{ + "disableAllHooks": true, + "hooks": { + "PreToolUse": [{"hooks":[{"command":"missing type"},{"type":"command","command":"valid"}]}] + } + }"#, + ) + .unwrap(); + + let provider = ClaudeCodeHookProvider::new(ClaudeCodeHookProviderOptions { + user_settings_file: user_settings, + project_root_override: Some(workspace.clone()), + project_settings_enabled: false, + }); + let snapshot = provider.discover(&context(&workspace)).unwrap(); + + assert_eq!(snapshot.entries.len(), 1); + assert_eq!( + snapshot.entries[0].native_activation, + ExternalHookNativeActivation::Disabled + ); + assert!(snapshot + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "claude.hook.handler_invalid")); + assert!(snapshot + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "claude.hook.all_disabled")); +} + +#[test] +fn changing_only_handler_secrets_does_not_change_catalog_versions() { + let root = tempdir().unwrap(); + let user_settings = root.path().join("settings.json"); + let workspace = root.path().join("workspace"); + fs::create_dir_all(&workspace).unwrap(); + let write = |secret: &str| { + fs::write( + &user_settings, + format!(r#"{{"hooks":{{"PreToolUse":[{{"hooks":[{{"type":"command","command":"{secret}"}}]}}]}}}}"#), + ) + .unwrap(); + }; + let provider = ClaudeCodeHookProvider::new(ClaudeCodeHookProviderOptions { + user_settings_file: user_settings.clone(), + project_root_override: Some(workspace.clone()), + project_settings_enabled: false, + }); + write("token-one"); + let first = provider.discover(&context(&workspace)).unwrap(); + write("token-two"); + let second = provider.discover(&context(&workspace)).unwrap(); + + assert_eq!( + first.sources[0].content_version, + second.sources[0].content_version + ); + assert_eq!( + first.entries[0].content_version, + second.entries[0].content_version + ); +} + +#[test] +fn layered_disable_applies_to_user_and_nested_project_hooks() { + let root = tempdir().unwrap(); + let user_settings = root.path().join("home/.claude/settings.json"); + let project = root.path().join("project"); + let workspace = project.join("packages/app"); + fs::create_dir_all(user_settings.parent().unwrap()).unwrap(); + for directory in [project.join(".claude"), workspace.join(".claude")] { + fs::create_dir_all(&directory).unwrap(); + fs::write( + directory.join("settings.json"), + r#"{"hooks":{"PostToolUse":[{"hooks":[{"type":"command","command":"private"}]}]}}"#, + ) + .unwrap(); + } + fs::write( + &user_settings, + r#"{"disableAllHooks":true,"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"private"}]}]}}"#, + ) + .unwrap(); + let provider = ClaudeCodeHookProvider::new(ClaudeCodeHookProviderOptions { + user_settings_file: user_settings, + project_root_override: Some(project), + project_settings_enabled: true, + }); + + let snapshot = provider.discover(&context(&workspace)).unwrap(); + + assert_eq!(snapshot.sources.len(), 3); + assert_eq!(snapshot.entries.len(), 3); + assert!(snapshot + .entries + .iter() + .all(|entry| { entry.native_activation == ExternalHookNativeActivation::Disabled })); +} + +#[test] +fn layered_activation_changes_update_every_affected_entry_version() { + let root = tempdir().unwrap(); + let user_settings = root.path().join("home/.claude/settings.json"); + let workspace = root.path().join("workspace"); + fs::create_dir_all(user_settings.parent().unwrap()).unwrap(); + fs::create_dir_all(workspace.join(".claude")).unwrap(); + fs::write( + workspace.join(".claude/settings.json"), + r#"{"hooks":{"PostToolUse":[{"hooks":[{"type":"command","command":"private"}]}]}}"#, + ) + .unwrap(); + let provider = ClaudeCodeHookProvider::new(ClaudeCodeHookProviderOptions { + user_settings_file: user_settings.clone(), + project_root_override: Some(workspace.clone()), + project_settings_enabled: true, + }); + fs::write(&user_settings, r#"{"disableAllHooks":false}"#).unwrap(); + let enabled = provider.discover(&context(&workspace)).unwrap(); + fs::write(&user_settings, r#"{"disableAllHooks":true}"#).unwrap(); + let disabled = provider.discover(&context(&workspace)).unwrap(); + let enabled_project = enabled + .entries + .iter() + .find(|entry| entry.native_event == "PostToolUse") + .unwrap(); + let disabled_project = disabled + .entries + .iter() + .find(|entry| entry.native_event == "PostToolUse") + .unwrap(); + + assert_eq!( + enabled_project.native_activation, + ExternalHookNativeActivation::Unknown + ); + assert_eq!( + disabled_project.native_activation, + ExternalHookNativeActivation::Disabled + ); + assert_ne!( + enabled_project.content_version, + disabled_project.content_version + ); +} diff --git a/src/crates/adapters/codex-adapter/AGENTS.md b/src/crates/adapters/codex-adapter/AGENTS.md new file mode 100644 index 0000000000..51ef3058df --- /dev/null +++ b/src/crates/adapters/codex-adapter/AGENTS.md @@ -0,0 +1,6 @@ +# Codex Adapter Instructions + +- This crate may read and normalize Codex configuration but must never invoke Hook handlers, start Codex/app-server, or own trust, enablement, or product policy. +- Keep commands, prompts, arguments, environment values, and credentials inside the adapter. Public contracts expose only bounded summaries. +- Preserve native Codex event and handler names. Map only reviewed BitFun Hook points; report every other event as native-only. +- Do not depend on another ecosystem adapter. Shared product behavior belongs in contracts or assembly. diff --git a/src/crates/adapters/codex-adapter/Cargo.toml b/src/crates/adapters/codex-adapter/Cargo.toml new file mode 100644 index 0000000000..1d06a80580 --- /dev/null +++ b/src/crates/adapters/codex-adapter/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "bitfun-codex-adapter" +version.workspace = true +authors.workspace = true +edition.workspace = true +description = "Codex static source adapter for BitFun" + +[lib] +name = "bitfun_codex_adapter" +crate-type = ["rlib"] + +[dependencies] +bitfun-product-domains = { path = "../../contracts/product-domains", default-features = false, features = ["external-sources"] } +bitfun-static-hook-support = { path = "../static-hook-support" } +dirs = { workspace = true } +hex = { workspace = true } +sha2 = { workspace = true } +serde_json = { workspace = true } +toml = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } + +[lints] +workspace = true diff --git a/src/crates/adapters/codex-adapter/src/hook_source.rs b/src/crates/adapters/codex-adapter/src/hook_source.rs new file mode 100644 index 0000000000..84343b8a42 --- /dev/null +++ b/src/crates/adapters/codex-adapter/src/hook_source.rs @@ -0,0 +1,669 @@ +use bitfun_product_domains::external_hook_catalog::{ + ExternalHookCatalogEntry, ExternalHookHandlerKind, ExternalHookMapping, + ExternalHookMatcherSummary, ExternalHookNativeActivation, ExternalHookProjectionStatus, + ExternalHookProviderIdentity, ExternalHookProviderSnapshot, ExternalHookSource, + ExternalHookSourceKind, ExternalHookSourceProvider, +}; +use bitfun_product_domains::external_hook_contributions::ExternalHookPoint; +use bitfun_product_domains::external_sources::{ + EcosystemId, ExternalSourceAssetKind, ExternalSourceContext, ExternalSourceDiagnostic, + ExternalSourceHealth, ExternalSourceProviderError, ExternalSourceScope, SourceKey, +}; +use bitfun_static_hook_support::{ + bounded_project_ancestors, parse_hook_document, read_bounded_file, + redacted_parse_content_version, regular_file_exists, BoundedFileRead, StaticHookDocumentFormat, + StaticHookHandlerFact, StaticHookHandlerRule, StaticHookParseIssue, StaticHookParseResult, +}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; + +const PROVIDER_ID: &str = "codex.hooks"; +const ECOSYSTEM_ID: &str = "codex"; +const MAX_CONFIG_FILE_BYTES: usize = 1024 * 1024; +const MAX_HANDLERS: usize = 2048; +const MAX_PROJECT_ANCESTORS: usize = 32; +const HANDLER_RULES: &[StaticHookHandlerRule] = &[ + StaticHookHandlerRule::new("command", ExternalHookHandlerKind::Command, &["command"]), + StaticHookHandlerRule::new("prompt", ExternalHookHandlerKind::Prompt, &[]), + StaticHookHandlerRule::new("agent", ExternalHookHandlerKind::Agent, &[]), +]; +const CODEX_HOOK_EVENTS: &[&str] = &[ + "PreToolUse", + "PermissionRequest", + "PostToolUse", + "PreCompact", + "PostCompact", + "SessionStart", + "SessionEnd", + "UserPromptSubmit", + "SubagentStart", + "SubagentStop", + "Stop", +]; + +#[derive(Debug, Clone)] +pub struct CodexHookProviderOptions { + pub codex_home: PathBuf, + /// Current checkout boundary used to bound project ancestor discovery. + pub project_root_override: Option, + /// Primary-checkout root used for linked-worktree Hook declarations. + pub project_hooks_root_override: Option, + pub project_hooks_enabled: bool, +} + +impl CodexHookProviderOptions { + pub fn from_environment() -> Self { + let codex_home = std::env::var_os("CODEX_HOME") + .map(PathBuf::from) + .or_else(|| dirs::home_dir().map(|home| home.join(".codex"))) + .unwrap_or_else(|| PathBuf::from(".codex")); + Self { + codex_home, + project_root_override: None, + project_hooks_root_override: None, + project_hooks_enabled: true, + } + } +} + +impl Default for CodexHookProviderOptions { + fn default() -> Self { + Self::from_environment() + } +} + +pub struct CodexHookProvider { + options: CodexHookProviderOptions, +} + +impl CodexHookProvider { + pub fn new(options: CodexHookProviderOptions) -> Self { + Self { options } + } + + fn project_roots( + &self, + workspace_root: &Path, + project_root_markers: &[String], + ) -> Vec { + let boundary = self + .options + .project_root_override + .as_deref() + .unwrap_or(workspace_root); + let project_root = if project_root_markers.is_empty() { + workspace_root.to_path_buf() + } else { + bounded_project_ancestors(workspace_root, boundary, MAX_PROJECT_ANCESTORS) + .into_iter() + .rev() + .find(|ancestor| { + project_root_markers + .iter() + .any(|marker| std::fs::metadata(ancestor.join(marker)).is_ok()) + }) + .unwrap_or_else(|| workspace_root.to_path_buf()) + }; + bounded_project_ancestors(workspace_root, &project_root, MAX_PROJECT_ANCESTORS) + } + + fn layers( + &self, + context: &ExternalSourceContext, + project_root_markers: &[String], + ) -> Vec { + let mut layers = vec![ + ConfigLayer::json( + self.options.codex_home.join("hooks.json"), + "user-hooks-json".to_string(), + "Codex user hooks".to_string(), + "~/.codex/hooks.json".to_string(), + ExternalSourceScope::UserGlobal, + ), + ConfigLayer::toml( + self.options.codex_home.join("config.toml"), + "user-config-toml".to_string(), + "Codex user configuration".to_string(), + "~/.codex/config.toml".to_string(), + ExternalSourceScope::UserGlobal, + ), + ]; + if self.options.project_hooks_enabled { + if let Some(workspace_root) = &context.workspace_root { + let boundary = self + .options + .project_root_override + .as_deref() + .unwrap_or(workspace_root); + for root in self.project_roots(workspace_root, project_root_markers) { + let relative = root.strip_prefix(boundary).unwrap_or(Path::new("")); + let config_dir = root.join(".codex"); + let hooks_dir = self + .options + .project_hooks_root_override + .as_ref() + .map(|main| main.join(relative).join(".codex")) + .unwrap_or_else(|| config_dir.clone()); + let hooks_path = hooks_dir.join("hooks.json"); + // Codex preserves ordinary linked-worktree configuration, + // but replaces the complete project `[hooks]` table with + // the matching primary-checkout layer. Both Hook source + // representations therefore share the same Hook root. + let config_path = hooks_dir.join("config.toml"); + layers.push(ConfigLayer::json( + hooks_path.clone(), + format!( + "project-hooks-json-{}", + short_hash(hooks_path.as_os_str().as_encoded_bytes()) + ), + "Codex project hooks".to_string(), + relative + .join(".codex/hooks.json") + .to_string_lossy() + .to_string(), + ExternalSourceScope::Project, + )); + layers.push(ConfigLayer::toml( + config_path.clone(), + format!( + "project-config-toml-{}", + short_hash(config_path.as_os_str().as_encoded_bytes()) + ), + "Codex project configuration".to_string(), + relative + .join(".codex/config.toml") + .to_string_lossy() + .to_string(), + ExternalSourceScope::Project, + )); + } + } + } + layers + } +} + +impl Default for CodexHookProvider { + fn default() -> Self { + Self::new(CodexHookProviderOptions::default()) + } +} + +impl ExternalHookSourceProvider for CodexHookProvider { + fn identity(&self) -> ExternalHookProviderIdentity { + ExternalHookProviderIdentity::new(PROVIDER_ID, ECOSYSTEM_ID, "Codex Hooks") + .expect("static Codex Hook provider identity must be valid") + } + + fn discover( + &self, + context: &ExternalSourceContext, + ) -> Result { + if context + .workspace_root + .as_ref() + .is_some_and(|workspace_root| !workspace_root.is_absolute()) + { + return Err(ExternalSourceProviderError::new( + "codex.hook.workspace_invalid", + "workspace root must be absolute", + false, + )); + } + let user_config_path = self.options.codex_home.join("config.toml"); + let user_config = load_config_file(&user_config_path)?; + let (project_root_markers, project_root_markers_invalid) = match &user_config { + Some(LoadedConfigFile::Content(bytes)) => match codex_toml_root(bytes) { + Some(root) => match codex_project_root_markers(&root) { + Ok(Some(markers)) => (markers, false), + Ok(None) => (vec![".git".to_string()], false), + Err(()) => (Vec::new(), true), + }, + None => (Vec::new(), false), + }, + Some(LoadedConfigFile::TooLarge) => (Vec::new(), false), + None => (vec![".git".to_string()], false), + }; + let layers = self.layers(context, &project_root_markers); + let paths = layers + .iter() + .map(|layer| layer.path.clone()) + .collect::>(); + let mut loaded_files = BTreeMap::new(); + if let Some(user_config) = user_config { + loaded_files.insert(user_config_path.clone(), user_config); + } + for path in paths { + if loaded_files.contains_key(&path) { + continue; + } + if let Some(loaded) = load_config_file(&path)? { + loaded_files.insert(path, loaded); + } + } + + let mut sources = Vec::new(); + let mut entries = Vec::new(); + let mut diagnostics = Vec::new(); + if project_root_markers_invalid { + diagnostics.push(hook_warning( + "codex.hook.project_root_markers_invalid", + "Codex project_root_markers must be an array of strings; project Hook discovery was limited to the workspace", + None, + )); + } + let mut remaining_handlers = MAX_HANDLERS; + for layer in layers { + let bytes = match loaded_files.get(&layer.path) { + Some(LoadedConfigFile::Content(bytes)) => Some(bytes.as_slice()), + Some(LoadedConfigFile::TooLarge) => None, + None => continue, + }; + inspect_layer( + &layer, + bytes, + &mut remaining_handlers, + &mut sources, + &mut entries, + &mut diagnostics, + )?; + } + if !sources.is_empty() { + diagnostics.push(hook_info( + "codex.hook.coverage_static_only", + "Codex plugin, managed, and session-provided Hook sources are outside this static file catalog", + None, + )); + diagnostics.push(hook_info( + "codex.hook.activation_not_evaluated", + "Static inspection does not evaluate Codex Hook trust or runtime activation", + None, + )); + } + diagnostics + .sort_by(|left, right| (&left.code, &left.message).cmp(&(&right.code, &right.message))); + let snapshot = ExternalHookProviderSnapshot { + provider: self.identity(), + sources, + entries, + diagnostics, + }; + snapshot.validate().map_err(|error| { + ExternalSourceProviderError::new( + "codex.hook.snapshot_invalid", + error.to_string(), + false, + ) + })?; + Ok(snapshot) + } +} + +#[derive(Clone, Copy)] +enum ConfigFormat { + Json, + Toml, +} + +enum LoadedConfigFile { + Content(Vec), + TooLarge, +} + +fn load_config_file(path: &Path) -> Result, ExternalSourceProviderError> { + match regular_file_exists(path) { + Ok(false) => Ok(None), + Ok(true) => match read_bounded_file(path, MAX_CONFIG_FILE_BYTES) { + Ok(BoundedFileRead::Content(bytes)) => Ok(Some(LoadedConfigFile::Content(bytes))), + Ok(BoundedFileRead::TooLarge) => Ok(Some(LoadedConfigFile::TooLarge)), + Err(error) => Err(ExternalSourceProviderError::new( + "codex.hook.config_unreadable", + format!("Codex Hook configuration could not be read: {error}"), + true, + )), + }, + Err(error) => Err(ExternalSourceProviderError::new( + "codex.hook.config_metadata_failed", + format!("Codex Hook configuration metadata is unavailable: {error}"), + true, + )), + } +} + +struct ConfigLayer { + path: PathBuf, + source_id: String, + label: String, + location_hint: String, + scope: ExternalSourceScope, + format: ConfigFormat, +} + +impl ConfigLayer { + fn json( + path: PathBuf, + source_id: String, + label: String, + location_hint: String, + scope: ExternalSourceScope, + ) -> Self { + Self { + path, + source_id, + label, + location_hint, + scope, + format: ConfigFormat::Json, + } + } + + fn toml( + path: PathBuf, + source_id: String, + label: String, + location_hint: String, + scope: ExternalSourceScope, + ) -> Self { + Self { + path, + source_id, + label, + location_hint, + scope, + format: ConfigFormat::Toml, + } + } +} + +fn inspect_layer( + layer: &ConfigLayer, + bytes: Option<&[u8]>, + remaining_handlers: &mut usize, + sources: &mut Vec, + entries: &mut Vec, + diagnostics: &mut Vec, +) -> Result<(), ExternalSourceProviderError> { + let source_key = SourceKey::new(PROVIDER_ID, &layer.source_id) + .expect("static Codex Hook source identity must be valid"); + let bytes = match bytes { + Some(bytes) => bytes, + None => { + let diagnostic = hook_warning( + "codex.hook.config_too_large", + "Codex Hook configuration exceeds the 1 MiB static inspection limit", + Some(source_key.clone()), + ); + sources.push(source( + layer, + source_key, + ExternalSourceHealth::Unavailable, + "unavailable:too-large".to_string(), + vec![diagnostic.clone()], + )); + diagnostics.push(diagnostic); + return Ok(()); + } + }; + let mut parsed = parse_hook_document( + bytes, + match layer.format { + ConfigFormat::Json => StaticHookDocumentFormat::Json, + ConfigFormat::Toml => StaticHookDocumentFormat::Toml, + }, + HANDLER_RULES, + *remaining_handlers, + ); + validate_codex_document(bytes, layer.format, &mut parsed); + let version = codex_redacted_content_version(&parsed); + let mut source_diagnostics = Vec::new(); + *remaining_handlers = remaining_handlers.saturating_sub(parsed.inspected_handlers); + for issue in parsed.issues { + source_diagnostics.push(parse_issue_diagnostic(issue, &source_key)); + } + for handler in parsed.handlers { + let native_activation = codex_native_activation(&handler); + entries.push(entry( + &source_key, + &handler.native_event, + handler.matcher, + handler.handler_kind, + handler.group_index, + handler.handler_index, + native_activation, + &version, + )); + } + let health = if source_diagnostics.is_empty() { + ExternalSourceHealth::Available + } else { + ExternalSourceHealth::Degraded + }; + diagnostics.extend(source_diagnostics.clone()); + sources.push(source( + layer, + source_key, + health, + version, + source_diagnostics, + )); + Ok(()) +} + +fn validate_codex_document(bytes: &[u8], format: ConfigFormat, parsed: &mut StaticHookParseResult) { + // `disableAllHooks` belongs to Claude Code. Codex state and feature facts + // are skipped because static discovery cannot determine activation. + parsed.all_disabled = false; + let root = match format { + ConfigFormat::Json => serde_json::from_slice::(bytes).ok(), + ConfigFormat::Toml => std::str::from_utf8(bytes) + .ok() + .and_then(|source| toml::from_str::(source).ok()) + .and_then(|value| serde_json::to_value(value).ok()), + }; + let Some(Value::Object(root)) = root else { + return; + }; + if matches!(format, ConfigFormat::Json) + && root + .keys() + .any(|key| key != "description" && key != "hooks") + { + parsed.handlers.clear(); + parsed.issues.clear(); + parsed.issues.push(StaticHookParseIssue::DocumentInvalid); + return; + } + let Some(Value::Object(events)) = root.get("hooks") else { + return; + }; + let has_unknown_event = events + .keys() + .any(|event| event != "state" && !CODEX_HOOK_EVENTS.contains(&event.as_str())); + if has_unknown_event { + parsed + .handlers + .retain(|handler| CODEX_HOOK_EVENTS.contains(&handler.native_event.as_str())); + if !parsed + .issues + .contains(&StaticHookParseIssue::EventNameInvalid) + { + parsed.issues.push(StaticHookParseIssue::EventNameInvalid); + } + } +} + +fn codex_toml_root(bytes: &[u8]) -> Option { + std::str::from_utf8(bytes) + .ok() + .and_then(|source| toml::from_str::(source).ok()) +} + +fn codex_project_root_markers(root: &toml::Value) -> Result>, ()> { + let Some(value) = root.get("project_root_markers") else { + return Ok(None); + }; + let Some(values) = value.as_array() else { + return Err(()); + }; + values + .iter() + .map(|value| value.as_str().map(str::to_string).ok_or(())) + .collect::, _>>() + .map(Some) +} + +fn codex_native_activation(handler: &StaticHookHandlerFact) -> ExternalHookNativeActivation { + if matches!( + handler.handler_kind, + ExternalHookHandlerKind::Prompt | ExternalHookHandlerKind::Agent + ) { + return ExternalHookNativeActivation::Unsupported; + } + // User state and feature flags may be overridden by unobserved session + // flags, while project declarations are trust-gated. Static inspection + // therefore cannot safely claim effective activation for command Hooks. + ExternalHookNativeActivation::Unknown +} + +fn codex_redacted_content_version(parsed: &StaticHookParseResult) -> String { + let mut material = redacted_parse_content_version(parsed); + for handler in &parsed.handlers { + material.push(':'); + material.push_str(match codex_native_activation(handler) { + ExternalHookNativeActivation::Disabled => "disabled", + ExternalHookNativeActivation::Unsupported => "unsupported", + _ => "unknown", + }); + } + content_hash(material.as_bytes()) +} + +fn parse_issue_diagnostic( + issue: StaticHookParseIssue, + source: &SourceKey, +) -> ExternalSourceDiagnostic { + let (code, message) = match issue { + StaticHookParseIssue::DocumentInvalid => ( + "codex.hook.config_parse_failed", + "Codex Hook configuration could not be parsed", + ), + StaticHookParseIssue::EventNameInvalid => ( + "codex.hook.event_name_invalid", + "Codex Hook event name is unsupported, invalid, or exceeds the inspection limit", + ), + StaticHookParseIssue::EventInvalid => ( + "codex.hook.event_invalid", + "Codex Hook event must contain an array of matcher groups", + ), + StaticHookParseIssue::GroupInvalid => ( + "codex.hook.group_invalid", + "Codex Hook matcher group must contain a hooks array", + ), + StaticHookParseIssue::HandlerInvalid => ( + "codex.hook.handler_invalid", + "Codex Hook handler is missing a supported type or required field", + ), + StaticHookParseIssue::HandlerLimit => ( + "codex.hook.handler_limit", + "Additional Codex Hook handlers were omitted after the 2048 item inspection limit", + ), + }; + hook_warning(code, message, Some(source.clone())) +} + +#[allow(clippy::too_many_arguments)] +fn entry( + source: &SourceKey, + native_event: &str, + matcher: ExternalHookMatcherSummary, + handler_kind: ExternalHookHandlerKind, + group_index: usize, + handler_index: usize, + native_activation: ExternalHookNativeActivation, + source_version: &str, +) -> ExternalHookCatalogEntry { + let mapping = match native_event { + "PreToolUse" => Some(ExternalHookMapping { + hook_point: ExternalHookPoint::ToolBefore, + }), + "PostToolUse" => Some(ExternalHookMapping { + hook_point: ExternalHookPoint::ToolAfter, + }), + _ => None, + }; + ExternalHookCatalogEntry { + stable_key: format!( + "codex-hook:{}", + short_hash( + format!( + "{}:{native_event}:{group_index}:{handler_index}", + source.stable_key() + ) + .as_bytes() + ) + ), + source: source.clone(), + native_event: native_event.to_string(), + matcher, + handler_kind, + projection_status: if mapping.is_some() { + ExternalHookProjectionStatus::Mapped + } else { + ExternalHookProjectionStatus::NativeOnly + }, + native_activation, + mapping, + content_version: content_hash( + format!("{source_version}:{native_event}:{group_index}:{handler_index}").as_bytes(), + ), + } +} + +fn source( + layer: &ConfigLayer, + key: SourceKey, + health: ExternalSourceHealth, + content_version: String, + diagnostics: Vec, +) -> ExternalHookSource { + ExternalHookSource { + key, + ecosystem_id: EcosystemId::new(ECOSYSTEM_ID) + .expect("static Codex ecosystem id must be valid"), + display_name: layer.label.to_string(), + source_kind: match layer.format { + ConfigFormat::Json => ExternalHookSourceKind::HooksFile, + ConfigFormat::Toml => ExternalHookSourceKind::InlineConfiguration, + }, + scope: layer.scope, + location_hint: layer.location_hint.to_string(), + health, + content_version, + diagnostics, + } +} + +fn hook_warning(code: &str, message: &str, source: Option) -> ExternalSourceDiagnostic { + ExternalSourceDiagnostic::warning(code, message, source) + .with_asset_kind(ExternalSourceAssetKind::Hook) +} + +fn hook_info(code: &str, message: &str, source: Option) -> ExternalSourceDiagnostic { + ExternalSourceDiagnostic { + severity: bitfun_product_domains::external_sources::ExternalSourceDiagnosticSeverity::Info, + asset_kind: ExternalSourceAssetKind::Hook, + code: code.to_string(), + message: message.to_string(), + source, + } +} + +fn content_hash(value: &[u8]) -> String { + format!("sha256:{}", hex::encode(Sha256::digest(value))) +} + +fn short_hash(value: &[u8]) -> String { + hex::encode(Sha256::digest(value))[..24].to_string() +} diff --git a/src/crates/adapters/codex-adapter/src/lib.rs b/src/crates/adapters/codex-adapter/src/lib.rs new file mode 100644 index 0000000000..320ad98233 --- /dev/null +++ b/src/crates/adapters/codex-adapter/src/lib.rs @@ -0,0 +1,5 @@ +//! Runtime-free Codex source adapter. + +mod hook_source; + +pub use hook_source::{CodexHookProvider, CodexHookProviderOptions}; diff --git a/src/crates/adapters/codex-adapter/tests/hook_source.rs b/src/crates/adapters/codex-adapter/tests/hook_source.rs new file mode 100644 index 0000000000..0fd013931a --- /dev/null +++ b/src/crates/adapters/codex-adapter/tests/hook_source.rs @@ -0,0 +1,525 @@ +use bitfun_codex_adapter::{CodexHookProvider, CodexHookProviderOptions}; +use bitfun_product_domains::external_hook_catalog::{ + ExternalHookHandlerKind, ExternalHookNativeActivation, ExternalHookProjectionStatus, + ExternalHookSourceProvider, +}; +use bitfun_product_domains::external_hook_contributions::ExternalHookPoint; +use bitfun_product_domains::external_sources::{ExecutionDomainId, ExternalSourceContext}; +use std::fs; +use tempfile::tempdir; + +fn context(workspace: &std::path::Path) -> ExternalSourceContext { + ExternalSourceContext { + workspace_root: Some(workspace.to_path_buf()), + execution_domain_id: ExecutionDomainId::new("local-user").unwrap(), + } +} + +#[test] +fn discovers_hooks_json_and_inline_toml_without_exposing_handler_content() { + let root = tempdir().unwrap(); + let codex_home = root.path().join("home/.codex"); + let workspace = root.path().join("workspace"); + fs::create_dir_all(&codex_home).unwrap(); + fs::create_dir_all(workspace.join(".codex")).unwrap(); + fs::write( + codex_home.join("hooks.json"), + r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"secret command"}]}],"SessionStart":[{"hooks":[{"type":"prompt","prompt":"private prompt"}]}]}}"#, + ) + .unwrap(); + fs::write( + codex_home.join("config.toml"), + r#" +[[hooks.PostToolUse]] +matcher = "Edit|Write" + +[[hooks.PostToolUse.hooks]] +type = "agent" +prompt = "private agent prompt" +"#, + ) + .unwrap(); + fs::write( + workspace.join(".codex/hooks.json"), + r#"{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"project secret"}]}]}}"#, + ) + .unwrap(); + + let provider = CodexHookProvider::new(CodexHookProviderOptions { + codex_home, + project_root_override: Some(workspace.clone()), + project_hooks_root_override: None, + project_hooks_enabled: true, + }); + let snapshot = provider.discover(&context(&workspace)).unwrap(); + + assert_eq!(snapshot.sources.len(), 3); + assert_eq!(snapshot.entries.len(), 4); + assert!(snapshot.entries.iter().any(|entry| { + entry.handler_kind == ExternalHookHandlerKind::Command + && entry.mapping.as_ref().map(|mapping| mapping.hook_point) + == Some(ExternalHookPoint::ToolBefore) + })); + assert!(snapshot.entries.iter().any(|entry| { + entry.handler_kind == ExternalHookHandlerKind::Agent + && entry.mapping.as_ref().map(|mapping| mapping.hook_point) + == Some(ExternalHookPoint::ToolAfter) + })); + assert!(snapshot.entries.iter().any(|entry| { + entry.native_event == "SessionStart" + && entry.projection_status == ExternalHookProjectionStatus::NativeOnly + })); + + let serialized = serde_json::to_string(&snapshot).unwrap(); + assert!(!serialized.contains("secret command")); + assert!(!serialized.contains("private prompt")); + assert!(!serialized.contains("private agent prompt")); + assert!(!serialized.contains("project secret")); +} + +#[test] +fn invalid_files_and_handlers_are_partial_diagnostics() { + let root = tempdir().unwrap(); + let codex_home = root.path().join("home/.codex"); + let workspace = root.path().join("workspace"); + fs::create_dir_all(&codex_home).unwrap(); + fs::create_dir_all(&workspace).unwrap(); + fs::write(codex_home.join("hooks.json"), "{ invalid").unwrap(); + fs::write( + codex_home.join("config.toml"), + r#" +[[hooks.PreToolUse]] +[[hooks.PreToolUse.hooks]] +type = "http" +url = "https://unsupported.example" +"#, + ) + .unwrap(); + + let provider = CodexHookProvider::new(CodexHookProviderOptions { + codex_home, + project_root_override: Some(workspace.clone()), + project_hooks_root_override: None, + project_hooks_enabled: false, + }); + let snapshot = provider.discover(&context(&workspace)).unwrap(); + + assert!(snapshot.entries.is_empty()); + assert_eq!(snapshot.sources.len(), 2); + assert!(snapshot + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "codex.hook.config_parse_failed")); + assert!(snapshot + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "codex.hook.handler_invalid")); +} + +#[test] +fn user_feature_gate_does_not_claim_effective_activation_without_session_layers() { + let root = tempdir().unwrap(); + let codex_home = root.path().join("home/.codex"); + let workspace = root.path().join("workspace"); + fs::create_dir_all(&codex_home).unwrap(); + fs::create_dir_all(&workspace).unwrap(); + fs::write( + codex_home.join("config.toml"), + r#" +[features] +hooks = false + +[[hooks.PreToolUse]] +[[hooks.PreToolUse.hooks]] +type = "command" +command = "never expose this" +"#, + ) + .unwrap(); + + let provider = CodexHookProvider::new(CodexHookProviderOptions { + codex_home, + project_root_override: Some(workspace.clone()), + project_hooks_root_override: None, + project_hooks_enabled: false, + }); + let snapshot = provider.discover(&context(&workspace)).unwrap(); + + assert_eq!(snapshot.entries.len(), 1); + assert_eq!( + snapshot.entries[0].native_activation, + ExternalHookNativeActivation::Unknown + ); + assert!(!snapshot + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "codex.hook.all_disabled")); + assert!(snapshot + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "codex.hook.coverage_static_only")); +} + +#[test] +fn rejects_unknown_hooks_json_root_fields_and_omits_unknown_events() { + let root = tempdir().unwrap(); + let codex_home = root.path().join("home/.codex"); + let workspace = root.path().join("workspace"); + fs::create_dir_all(&codex_home).unwrap(); + fs::create_dir_all(&workspace).unwrap(); + fs::write( + codex_home.join("hooks.json"), + r#"{"hooks":{"PreToolUes":[{"hooks":[{"type":"command","command":"typo"}]}]},"unexpected":true}"#, + ) + .unwrap(); + fs::write( + codex_home.join("config.toml"), + r#" +[[hooks.PreToolUes]] +[[hooks.PreToolUes.hooks]] +type = "command" +command = "typo" +"#, + ) + .unwrap(); + let provider = CodexHookProvider::new(CodexHookProviderOptions { + codex_home, + project_root_override: Some(workspace.clone()), + project_hooks_root_override: None, + project_hooks_enabled: false, + }); + + let snapshot = provider.discover(&context(&workspace)).unwrap(); + + assert!(snapshot.entries.is_empty()); + assert!(snapshot.diagnostics.iter().any(|diagnostic| { + matches!( + diagnostic.code.as_str(), + "codex.hook.config_parse_failed" | "codex.hook.event_name_invalid" + ) + })); +} + +#[test] +fn user_state_does_not_claim_effective_activation_without_session_layers() { + let root = tempdir().unwrap(); + let codex_home = root.path().join("home/.codex"); + let workspace = root.path().join("workspace"); + fs::create_dir_all(&codex_home).unwrap(); + fs::create_dir_all(&workspace).unwrap(); + let config_path = codex_home.join("config.toml"); + let state_key = format!("{}:pre_tool_use:0:0", config_path.display()).replace('\\', "\\\\"); + fs::write( + &config_path, + format!( + r#" +[[hooks.PreToolUse]] +[[hooks.PreToolUse.hooks]] +type = "command" +command = "private" + +[hooks.state."{state_key}"] +enabled = false +trusted_hash = "must-not-be-exposed" +"# + ), + ) + .unwrap(); + let provider = CodexHookProvider::new(CodexHookProviderOptions { + codex_home, + project_root_override: Some(workspace.clone()), + project_hooks_root_override: None, + project_hooks_enabled: false, + }); + + let snapshot = provider.discover(&context(&workspace)).unwrap(); + + assert_eq!( + snapshot.entries[0].native_activation, + ExternalHookNativeActivation::Unknown + ); + assert!(!serde_json::to_string(&snapshot) + .unwrap() + .contains("must-not-be-exposed")); +} + +#[test] +fn user_state_does_not_claim_project_hook_activation_without_session_layers() { + let root = tempdir().unwrap(); + let codex_home = root.path().join("home/.codex"); + let workspace = root.path().join("workspace"); + let project_codex = workspace.join(".codex"); + fs::create_dir_all(&codex_home).unwrap(); + fs::create_dir_all(&project_codex).unwrap(); + let project_hooks = project_codex.join("hooks.json"); + fs::write( + &project_hooks, + r#"{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"private"}]}]}}"#, + ) + .unwrap(); + let state_key = format!("{}:pre_tool_use:0:0", project_hooks.display()).replace('\\', "\\\\"); + fs::write( + codex_home.join("config.toml"), + format!( + r#" +[hooks.state."{state_key}"] +enabled = false +"# + ), + ) + .unwrap(); + let provider = CodexHookProvider::new(CodexHookProviderOptions { + codex_home, + project_root_override: Some(workspace.clone()), + project_hooks_root_override: None, + project_hooks_enabled: true, + }); + + let snapshot = provider.discover(&context(&workspace)).unwrap(); + let project_entry = snapshot + .entries + .iter() + .find(|entry| { + entry + .source + .source_id + .as_str() + .starts_with("project-hooks-json-") + }) + .unwrap(); + assert_eq!( + project_entry.native_activation, + ExternalHookNativeActivation::Unknown + ); +} + +#[test] +fn changing_only_a_command_secret_does_not_change_catalog_versions() { + let root = tempdir().unwrap(); + let codex_home = root.path().join("home/.codex"); + let workspace = root.path().join("workspace"); + fs::create_dir_all(&codex_home).unwrap(); + fs::create_dir_all(&workspace).unwrap(); + let hooks = codex_home.join("hooks.json"); + let write = |secret: &str| { + fs::write( + &hooks, + format!(r#"{{"hooks":{{"PreToolUse":[{{"hooks":[{{"type":"command","command":"{secret}"}}]}}]}}}}"#), + ) + .unwrap(); + }; + let provider = CodexHookProvider::new(CodexHookProviderOptions { + codex_home, + project_root_override: Some(workspace.clone()), + project_hooks_root_override: None, + project_hooks_enabled: false, + }); + write("token-one"); + let first = provider.discover(&context(&workspace)).unwrap(); + write("token-two"); + let second = provider.discover(&context(&workspace)).unwrap(); + + assert_eq!( + first.sources[0].content_version, + second.sources[0].content_version + ); + assert_eq!( + first.entries[0].content_version, + second.entries[0].content_version + ); +} + +#[test] +fn linked_worktree_hooks_use_primary_checkout_across_nested_project_layers() { + let root = tempdir().unwrap(); + let codex_home = root.path().join("home/.codex"); + let linked = root.path().join("worktrees/feature"); + let workspace = linked.join("packages/app"); + let primary = root.path().join("repo"); + fs::create_dir_all(&codex_home).unwrap(); + fs::create_dir_all(&linked).unwrap(); + fs::write( + linked.join(".git"), + "gitdir: ../../repo/.git/worktrees/feature", + ) + .unwrap(); + for directory in [primary.join(".codex"), primary.join("packages/app/.codex")] { + fs::create_dir_all(&directory).unwrap(); + fs::write( + directory.join("hooks.json"), + r#"{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"private"}]}]}}"#, + ) + .unwrap(); + } + fs::create_dir_all(&workspace).unwrap(); + let provider = CodexHookProvider::new(CodexHookProviderOptions { + codex_home, + project_root_override: Some(linked), + project_hooks_root_override: Some(primary), + project_hooks_enabled: true, + }); + + let snapshot = provider.discover(&context(&workspace)).unwrap(); + + assert_eq!(snapshot.sources.len(), 2); + assert_eq!(snapshot.entries.len(), 2); + assert!(snapshot + .sources + .iter() + .any(|source| source.location_hint == ".codex/hooks.json")); + assert!(snapshot.sources.iter().any(|source| source + .location_hint + .replace('\\', "/") + .ends_with("packages/app/.codex/hooks.json"))); +} + +#[test] +fn linked_worktree_inline_hooks_use_primary_checkout_and_ignore_project_state() { + let root = tempdir().unwrap(); + let codex_home = root.path().join("home/.codex"); + let linked = root.path().join("worktrees/feature"); + let primary = root.path().join("repo"); + fs::create_dir_all(&codex_home).unwrap(); + fs::create_dir_all(linked.join(".codex")).unwrap(); + fs::create_dir_all(primary.join(".codex")).unwrap(); + fs::write( + linked.join(".git"), + "gitdir: ../../repo/.git/worktrees/feature", + ) + .unwrap(); + let primary_config = primary.join(".codex/config.toml"); + let state_key = format!("{}:pre_tool_use:0:0", primary_config.display()).replace('\\', "\\\\"); + fs::write( + &primary_config, + format!( + r#" +[[hooks.PreToolUse]] +[[hooks.PreToolUse.hooks]] +type = "command" +command = "primary private" + +[hooks.state."{state_key}"] +enabled = false +"# + ), + ) + .unwrap(); + fs::write( + linked.join(".codex/config.toml"), + r#" +[features] +hooks = false + +[[hooks.PostToolUse]] +[[hooks.PostToolUse.hooks]] +type = "command" +command = "linked private" +"#, + ) + .unwrap(); + let provider = CodexHookProvider::new(CodexHookProviderOptions { + codex_home, + project_root_override: Some(linked.clone()), + project_hooks_root_override: Some(primary), + project_hooks_enabled: true, + }); + + let snapshot = provider.discover(&context(&linked)).unwrap(); + + assert_eq!(snapshot.entries.len(), 1); + assert_eq!(snapshot.entries[0].native_event, "PreToolUse"); + assert_eq!( + snapshot.entries[0].native_activation, + ExternalHookNativeActivation::Unknown + ); + assert!(!serde_json::to_string(&snapshot) + .unwrap() + .contains("linked private")); +} + +#[test] +fn custom_project_root_markers_bound_codex_ancestor_layers() { + let root = tempdir().unwrap(); + let codex_home = root.path().join("home/.codex"); + let project = root.path().join("project"); + let package = project.join("packages"); + let workspace = package.join("app"); + fs::create_dir_all(&codex_home).unwrap(); + fs::write( + codex_home.join("config.toml"), + r#"project_root_markers = ["package.json"]"#, + ) + .unwrap(); + fs::create_dir_all(project.join(".codex")).unwrap(); + fs::create_dir_all(package.join(".codex")).unwrap(); + fs::create_dir_all(workspace.join(".codex")).unwrap(); + fs::write(package.join("package.json"), "{}").unwrap(); + for (directory, event) in [ + (project.join(".codex"), "SessionStart"), + (package.join(".codex"), "PreToolUse"), + (workspace.join(".codex"), "PostToolUse"), + ] { + fs::write( + directory.join("hooks.json"), + format!( + r#"{{"hooks":{{"{event}":[{{"hooks":[{{"type":"command","command":"private"}}]}}]}}}}"# + ), + ) + .unwrap(); + } + let provider = CodexHookProvider::new(CodexHookProviderOptions { + codex_home, + project_root_override: Some(project), + project_hooks_root_override: None, + project_hooks_enabled: true, + }); + + let snapshot = provider.discover(&context(&workspace)).unwrap(); + + assert_eq!(snapshot.entries.len(), 2); + assert!(snapshot + .entries + .iter() + .any(|entry| entry.native_event == "PreToolUse")); + assert!(snapshot + .entries + .iter() + .any(|entry| entry.native_event == "PostToolUse")); + assert!(!snapshot + .entries + .iter() + .any(|entry| entry.native_event == "SessionStart")); +} + +#[test] +fn empty_project_root_markers_limit_codex_hooks_to_workspace() { + let root = tempdir().unwrap(); + let codex_home = root.path().join("home/.codex"); + let project = root.path().join("project"); + let workspace = project.join("packages/app"); + fs::create_dir_all(&codex_home).unwrap(); + fs::write(codex_home.join("config.toml"), "project_root_markers = []").unwrap(); + fs::create_dir_all(project.join(".codex")).unwrap(); + fs::create_dir_all(workspace.join(".codex")).unwrap(); + fs::write( + project.join(".codex/hooks.json"), + r#"{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"private"}]}]}}"#, + ) + .unwrap(); + fs::write( + workspace.join(".codex/hooks.json"), + r#"{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"private"}]}]}}"#, + ) + .unwrap(); + let provider = CodexHookProvider::new(CodexHookProviderOptions { + codex_home, + project_root_override: Some(project), + project_hooks_root_override: None, + project_hooks_enabled: true, + }); + + let snapshot = provider.discover(&context(&workspace)).unwrap(); + + assert_eq!(snapshot.entries.len(), 1); + assert_eq!(snapshot.entries[0].native_event, "PreToolUse"); +} diff --git a/src/crates/adapters/opencode-adapter/Cargo.toml b/src/crates/adapters/opencode-adapter/Cargo.toml index 7653900049..7bb2f9ad75 100644 --- a/src/crates/adapters/opencode-adapter/Cargo.toml +++ b/src/crates/adapters/opencode-adapter/Cargo.toml @@ -15,6 +15,7 @@ bitfun-plugin-runtime-host = { path = "../../execution/plugin-runtime-host" } bitfun-product-domains = { path = "../../contracts/product-domains", default-features = false, features = ["external-sources", "plugin-source"] } bitfun-runtime-ports = { path = "../../contracts/runtime-ports" } bitfun-services-core = { path = "../../services/services-core", default-features = false, features = ["markdown"] } +bitfun-static-hook-support = { path = "../static-hook-support" } dirs = { workspace = true } dunce = { workspace = true } hex = { workspace = true } diff --git a/src/crates/adapters/opencode-adapter/src/command_source.rs b/src/crates/adapters/opencode-adapter/src/command_source.rs index 85efeb52c1..a5d157d12b 100644 --- a/src/crates/adapters/opencode-adapter/src/command_source.rs +++ b/src/crates/adapters/opencode-adapter/src/command_source.rs @@ -1005,7 +1005,10 @@ fn command_content_version(name: &str, input: &OpenCodeCommandInput) -> String { format!("sha256:{}", hex::encode(hasher.finalize())) } -fn opencode_user_config_dir(xdg_config_home: Option, home: Option) -> PathBuf { +pub(crate) fn opencode_user_config_dir( + xdg_config_home: Option, + home: Option, +) -> PathBuf { xdg_config_home .or_else(|| home.map(|home| home.join(".config"))) .unwrap_or_else(|| PathBuf::from(".config")) @@ -1018,7 +1021,7 @@ fn environment_truthy(key: &str) -> bool { .is_some_and(|value| matches!(value.to_ascii_lowercase().as_str(), "true" | "1")) } -fn strip_jsonc(input: &str) -> String { +pub(crate) fn strip_jsonc(input: &str) -> String { let mut without_comments = String::with_capacity(input.len()); let chars = input.chars().collect::>(); let mut index = 0; diff --git a/src/crates/adapters/opencode-adapter/src/hook_source.rs b/src/crates/adapters/opencode-adapter/src/hook_source.rs new file mode 100644 index 0000000000..fb17413d6e --- /dev/null +++ b/src/crates/adapters/opencode-adapter/src/hook_source.rs @@ -0,0 +1,771 @@ +use crate::{ + command_source::{opencode_user_config_dir, strip_jsonc}, + source_adapter::statically_discover_hook_events, +}; +use bitfun_product_domains::external_hook_catalog::{ + ExternalHookCatalogEntry, ExternalHookHandlerKind, ExternalHookMapping, + ExternalHookMatcherSummary, ExternalHookNativeActivation, ExternalHookProjectionStatus, + ExternalHookProviderIdentity, ExternalHookProviderSnapshot, ExternalHookSource, + ExternalHookSourceKind, ExternalHookSourceProvider, +}; +use bitfun_product_domains::external_hook_contributions::ExternalHookPoint; +use bitfun_product_domains::external_sources::{ + EcosystemId, ExternalSourceAssetKind, ExternalSourceContext, ExternalSourceDiagnostic, + ExternalSourceDiagnosticSeverity, ExternalSourceHealth, ExternalSourceProviderError, + ExternalSourceScope, SourceKey, +}; +use bitfun_static_hook_support::{ + bounded_project_ancestors, read_bounded_file, regular_file_exists, BoundedFileRead, +}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::collections::BTreeSet; +use std::fs; +use std::path::{Path, PathBuf}; + +const PROVIDER_ID: &str = "opencode.hooks"; +const ECOSYSTEM_ID: &str = "opencode"; +const MAX_PLUGIN_FILES: usize = 128; +const MAX_PLUGIN_DIRECTORY_ENTRIES: usize = 1024; +const MAX_HOOK_ENTRIES: usize = 2048; +const MAX_PLUGIN_FILE_BYTES: usize = 512 * 1024; +const MAX_CONFIG_FILE_BYTES: usize = 1024 * 1024; +const MAX_PACKAGE_DECLARATIONS: usize = 128; +const MAX_PROJECT_ANCESTORS: usize = 32; +const GLOBAL_CONFIG_NAMES: &[&str] = &["config.json", "opencode.json", "opencode.jsonc"]; +const DIRECTORY_CONFIG_NAMES: &[&str] = &["opencode.json", "opencode.jsonc"]; + +#[derive(Debug, Clone)] +pub struct OpenCodeHookProviderOptions { + pub user_config_dir: PathBuf, + pub legacy_user_config_dir: Option, + pub explicit_config_dir: Option, + pub project_config_enabled: bool, + pub project_root_override: Option, +} + +impl OpenCodeHookProviderOptions { + pub fn from_environment() -> Self { + let home = dirs::home_dir(); + let user_config_dir = opencode_user_config_dir( + std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from), + home.clone(), + ); + Self { + user_config_dir, + legacy_user_config_dir: home.map(|home| home.join(".opencode")), + explicit_config_dir: std::env::var_os("OPENCODE_CONFIG_DIR").map(PathBuf::from), + project_config_enabled: !environment_truthy("OPENCODE_DISABLE_PROJECT_CONFIG"), + project_root_override: None, + } + } +} + +impl Default for OpenCodeHookProviderOptions { + fn default() -> Self { + Self::from_environment() + } +} + +pub struct OpenCodeHookProvider { + options: OpenCodeHookProviderOptions, +} + +impl OpenCodeHookProvider { + pub fn new(options: OpenCodeHookProviderOptions) -> Self { + Self { options } + } + + fn project_roots(&self, workspace_root: &Path) -> Vec { + let boundary = self + .options + .project_root_override + .as_deref() + .unwrap_or(workspace_root); + bounded_project_ancestors(workspace_root, boundary, MAX_PROJECT_ANCESTORS) + } +} + +impl Default for OpenCodeHookProvider { + fn default() -> Self { + Self::new(OpenCodeHookProviderOptions::default()) + } +} + +impl ExternalHookSourceProvider for OpenCodeHookProvider { + fn identity(&self) -> ExternalHookProviderIdentity { + ExternalHookProviderIdentity::new(PROVIDER_ID, ECOSYSTEM_ID, "OpenCode Hooks") + .expect("static OpenCode Hook provider identity must be valid") + } + + fn discover( + &self, + context: &ExternalSourceContext, + ) -> Result { + if context + .workspace_root + .as_ref() + .is_some_and(|workspace_root| !workspace_root.is_absolute()) + { + return Err(ExternalSourceProviderError::new( + "opencode.hook.workspace_invalid", + "workspace root must be absolute", + false, + )); + } + + let mut layers = vec![HookLayer::new( + self.options.user_config_dir.clone(), + ExternalSourceScope::UserGlobal, + "OpenCode user configuration", + "OpenCode user plugins", + GLOBAL_CONFIG_NAMES, + true, + )]; + if let Some(path) = &self.options.legacy_user_config_dir { + layers.push(HookLayer::new( + path.clone(), + ExternalSourceScope::UserGlobal, + "OpenCode legacy configuration", + "OpenCode legacy plugins", + GLOBAL_CONFIG_NAMES, + true, + )); + } + if self.options.project_config_enabled { + if let Some(workspace_root) = &context.workspace_root { + for project_root in self.project_roots(workspace_root) { + layers.push(HookLayer::new( + project_root.clone(), + ExternalSourceScope::Project, + "OpenCode project configuration", + "OpenCode project plugins", + DIRECTORY_CONFIG_NAMES, + false, + )); + layers.push(HookLayer::new( + project_root.join(".opencode"), + ExternalSourceScope::Project, + "OpenCode project directory configuration", + "OpenCode project plugins", + DIRECTORY_CONFIG_NAMES, + true, + )); + } + } + } + // OpenCode applies OPENCODE_CONFIG_DIR after automatically discovered + // project directories, so preserve that precedence in the projection. + if let Some(path) = &self.options.explicit_config_dir { + layers.push(HookLayer::new( + path.clone(), + ExternalSourceScope::WorkspaceLocal, + "OpenCode explicit configuration", + "OpenCode explicit plugins", + DIRECTORY_CONFIG_NAMES, + true, + )); + } + deduplicate_layers(&mut layers); + + let mut sources = Vec::new(); + let mut entries = Vec::new(); + let mut diagnostics = Vec::new(); + let mut remaining_files = MAX_PLUGIN_FILES; + let mut remaining_entries = MAX_HOOK_ENTRIES; + let mut remaining_packages = MAX_PACKAGE_DECLARATIONS; + let mut package_limit_reported = false; + let mut file_limit_reported = false; + let mut directory_limit_reported = false; + let mut entry_limit_reported = false; + for layer in layers { + discover_package_declarations( + &layer, + &mut remaining_packages, + &mut package_limit_reported, + &mut sources, + &mut diagnostics, + )?; + if layer.scan_plugin_dirs { + for directory_name in ["plugin", "plugins"] { + discover_plugin_files( + &layer, + directory_name, + &mut remaining_files, + &mut remaining_entries, + &mut file_limit_reported, + &mut directory_limit_reported, + &mut entry_limit_reported, + &mut sources, + &mut entries, + &mut diagnostics, + )?; + } + } + } + diagnostics + .sort_by(|left, right| (&left.code, &left.message).cmp(&(&right.code, &right.message))); + + let snapshot = ExternalHookProviderSnapshot { + provider: self.identity(), + sources, + entries, + diagnostics, + }; + snapshot.validate().map_err(|error| { + ExternalSourceProviderError::new( + "opencode.hook.snapshot_invalid", + error.to_string(), + false, + ) + })?; + Ok(snapshot) + } +} + +struct HookLayer { + root: PathBuf, + scope: ExternalSourceScope, + config_label: &'static str, + plugin_label: &'static str, + config_names: &'static [&'static str], + scan_plugin_dirs: bool, +} + +impl HookLayer { + fn new( + root: PathBuf, + scope: ExternalSourceScope, + config_label: &'static str, + plugin_label: &'static str, + config_names: &'static [&'static str], + scan_plugin_dirs: bool, + ) -> Self { + Self { + root, + scope, + config_label, + plugin_label, + config_names, + scan_plugin_dirs, + } + } +} + +fn deduplicate_layers(layers: &mut Vec) { + let mut seen = BTreeSet::new(); + layers.retain(|layer| seen.insert(layer.root.clone())); +} + +fn discover_package_declarations( + layer: &HookLayer, + remaining_packages: &mut usize, + package_limit_reported: &mut bool, + sources: &mut Vec, + diagnostics: &mut Vec, +) -> Result<(), ExternalSourceProviderError> { + for config_name in layer.config_names { + let config_path = layer.root.join(config_name); + match regular_file_exists(&config_path) { + Ok(true) => {} + Ok(false) => continue, + Err(error) => { + return Err(ExternalSourceProviderError::new( + "opencode.hook.config_metadata_failed", + format!("OpenCode Hook configuration metadata is unavailable: {error}"), + true, + )) + } + } + let bytes = match read_bounded_file(&config_path, MAX_CONFIG_FILE_BYTES) { + Ok(BoundedFileRead::Content(bytes)) => bytes, + Ok(BoundedFileRead::TooLarge) => { + diagnostics.push(hook_warning( + "opencode.hook.config_too_large", + "OpenCode Hook configuration exceeds the 1 MiB inspection limit", + None, + )); + continue; + } + Err(error) => { + return Err(ExternalSourceProviderError::new( + "opencode.hook.config_unreadable", + format!("OpenCode Hook configuration could not be read: {error}"), + true, + )) + } + }; + let value = std::str::from_utf8(&bytes) + .ok() + .and_then(|source| serde_json::from_str::(&strip_jsonc(source)).ok()); + let Some(value) = value else { + diagnostics.push(hook_warning( + "opencode.hook.config_parse_failed", + "OpenCode Hook configuration is not valid JSON or JSONC", + None, + )); + continue; + }; + let Some(packages) = value.get("plugin").and_then(Value::as_array) else { + continue; + }; + for (index, package) in packages.iter().enumerate() { + if *remaining_packages == 0 { + if !*package_limit_reported { + diagnostics.push(hook_warning( + "opencode.hook.package_limit", + "Additional OpenCode plugin declarations were omitted after the 128 item inspection limit", + None, + )); + *package_limit_reported = true; + } + return Ok(()); + } + *remaining_packages -= 1; + let Some(_specifier) = plugin_specifier(package) else { + continue; + }; + let source_key = source_key("package", &format!("{}:{index}", config_path.display())); + let diagnostic = hook_info( + "opencode.hook.package_declared_only", + "OpenCode plugin is declared, but its Hook exports are not inspected until a separate runtime host resolves the declaration", + Some(source_key.clone()), + ); + sources.push(ExternalHookSource { + key: source_key.clone(), + ecosystem_id: ecosystem_id(), + display_name: "OpenCode configured plugin".to_string(), + source_kind: ExternalHookSourceKind::PackageDeclaration, + scope: layer.scope, + location_hint: format!("{}/{}", layer.config_label, config_name), + health: ExternalSourceHealth::Partial, + content_version: content_hash(b"package-declaration:redacted"), + diagnostics: vec![diagnostic.clone()], + }); + diagnostics.push(diagnostic); + } + } + Ok(()) +} + +fn plugin_specifier(value: &Value) -> Option<&str> { + value + .as_str() + .or_else(|| value.as_array()?.first()?.as_str()) + .filter(|value| !value.trim().is_empty()) +} + +fn discover_plugin_files( + layer: &HookLayer, + directory_name: &str, + remaining_files: &mut usize, + remaining_entries: &mut usize, + file_limit_reported: &mut bool, + directory_limit_reported: &mut bool, + entry_limit_reported: &mut bool, + sources: &mut Vec, + entries: &mut Vec, + diagnostics: &mut Vec, +) -> Result<(), ExternalSourceProviderError> { + if *remaining_entries == 0 { + report_entry_limit(entry_limit_reported, diagnostics); + return Ok(()); + } + let directory = layer.root.join(directory_name); + let read_dir = match fs::read_dir(&directory) { + Ok(read_dir) => read_dir, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => { + return Err(ExternalSourceProviderError::new( + "opencode.hook.plugin_directory_unreadable", + format!("OpenCode plugin directory could not be read: {error}"), + true, + )) + } + }; + let mut paths = Vec::new(); + for (index, entry) in read_dir.enumerate() { + if index == MAX_PLUGIN_DIRECTORY_ENTRIES { + if !*directory_limit_reported { + diagnostics.push(hook_warning( + "opencode.hook.plugin_directory_limit", + "Additional OpenCode plugin directory entries were omitted after the 1024 item inspection limit", + None, + )); + *directory_limit_reported = true; + } + break; + } + let entry = entry.map_err(|error| { + ExternalSourceProviderError::new( + "opencode.hook.plugin_directory_entry_unreadable", + format!("OpenCode plugin directory entry could not be read: {error}"), + true, + ) + })?; + let path = entry.path(); + let file_type = entry.file_type().map_err(|error| { + ExternalSourceProviderError::new( + "opencode.hook.plugin_metadata_failed", + format!("OpenCode plugin metadata is unavailable: {error}"), + true, + ) + })?; + let is_file = if file_type.is_symlink() { + match fs::metadata(&path) { + Ok(metadata) => metadata.is_file(), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + diagnostics.push(hook_warning( + "opencode.hook.plugin_symlink_unavailable", + "An OpenCode plugin symlink target is unavailable and was omitted", + None, + )); + false + } + Err(error) => { + return Err(ExternalSourceProviderError::new( + "opencode.hook.plugin_metadata_failed", + format!("OpenCode plugin symlink metadata is unavailable: {error}"), + true, + )); + } + } + } else { + file_type.is_file() + }; + if is_file + && matches!( + path.extension().and_then(|extension| extension.to_str()), + Some("js" | "ts") + ) + && !path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(".d.ts")) + { + paths.push(path); + } + } + paths.sort(); + + for path in paths { + if *remaining_files == 0 { + if !*file_limit_reported { + diagnostics.push(hook_warning( + "opencode.hook.plugin_file_limit", + "Additional OpenCode plugin files were omitted after the 128 file inspection limit", + None, + )); + *file_limit_reported = true; + } + break; + } + if *remaining_entries == 0 { + report_entry_limit(entry_limit_reported, diagnostics); + break; + } + *remaining_files -= 1; + let source_key = source_key("plugin", &path.to_string_lossy()); + let location_hint = path + .file_name() + .and_then(|name| name.to_str()) + .map(|name| format!("{} ({directory_name})/{name}", layer.plugin_label)) + .unwrap_or_else(|| layer.plugin_label.to_string()); + let bytes = match read_bounded_file(&path, MAX_PLUGIN_FILE_BYTES) { + Ok(BoundedFileRead::Content(bytes)) => bytes, + Ok(BoundedFileRead::TooLarge) => { + let diagnostic = hook_warning( + "opencode.hook.plugin_too_large", + "OpenCode plugin exceeds the 512 KiB static inspection limit", + Some(source_key.clone()), + ); + sources.push(source( + source_key, + layer.scope, + location_hint, + ExternalSourceHealth::Unavailable, + "unavailable:too-large".to_string(), + vec![diagnostic.clone()], + )); + diagnostics.push(diagnostic); + continue; + } + Err(error) => { + return Err(ExternalSourceProviderError::new( + "opencode.hook.plugin_unreadable", + format!( + "OpenCode plugin could not be read for static Hook inspection: {error}" + ), + true, + )) + } + }; + let parsed = std::str::from_utf8(&bytes) + .map_err(|_| "source is not UTF-8".to_string()) + .and_then(|source| statically_discover_hook_events(&path, source)); + match parsed { + Ok(discovery) => { + let version = redacted_discovery_version(&discovery); + let mut source_diagnostics = Vec::new(); + let has_opaque = !discovery.opaque_events.is_empty() + || !discovery.dynamic_registrations.is_empty(); + for event in discovery.events { + if !take_entry(remaining_entries, entry_limit_reported, diagnostics) { + break; + } + entries.push(entry( + &source_key, + event.native_event, + &event.registration_id, + )); + } + for event in discovery.opaque_events { + if !take_entry(remaining_entries, entry_limit_reported, diagnostics) { + break; + } + entries.push(opaque_entry( + &source_key, + event.native_event, + &event.registration_id, + ExternalHookMatcherSummary::Unavailable, + )); + } + for registration_id in discovery.dynamic_registrations { + if !take_entry(remaining_entries, entry_limit_reported, diagnostics) { + break; + } + entries.push(opaque_entry( + &source_key, + "".to_string(), + ®istration_id, + ExternalHookMatcherSummary::Dynamic, + )); + } + if has_opaque { + let diagnostic = hook_info( + "opencode.hook.registration_opaque", + "OpenCode plugin contains Hook registration that cannot be mapped safely by static inspection", + Some(source_key.clone()), + ); + source_diagnostics.push(diagnostic.clone()); + diagnostics.push(diagnostic); + } + sources.push(source( + source_key, + layer.scope, + location_hint, + if source_diagnostics.is_empty() { + ExternalSourceHealth::Available + } else { + ExternalSourceHealth::Partial + }, + version, + source_diagnostics, + )); + } + Err(_) => { + let diagnostic = hook_warning( + "opencode.hook.plugin_parse_failed", + "OpenCode plugin could not be parsed statically; no handler was loaded or executed", + Some(source_key.clone()), + ); + sources.push(source( + source_key, + layer.scope, + location_hint, + ExternalSourceHealth::Degraded, + "invalid:parse".to_string(), + vec![diagnostic.clone()], + )); + diagnostics.push(diagnostic); + } + } + } + Ok(()) +} + +fn take_entry( + remaining_entries: &mut usize, + entry_limit_reported: &mut bool, + diagnostics: &mut Vec, +) -> bool { + if *remaining_entries == 0 { + report_entry_limit(entry_limit_reported, diagnostics); + return false; + } + *remaining_entries -= 1; + true +} + +fn report_entry_limit( + entry_limit_reported: &mut bool, + diagnostics: &mut Vec, +) { + if !*entry_limit_reported { + diagnostics.push(hook_warning( + "opencode.hook.entry_limit", + "Additional OpenCode Hook registrations were omitted after the 2048 entry inspection limit", + None, + )); + *entry_limit_reported = true; + } +} + +fn source( + key: SourceKey, + scope: ExternalSourceScope, + location_hint: String, + health: ExternalSourceHealth, + content_version: String, + diagnostics: Vec, +) -> ExternalHookSource { + ExternalHookSource { + key, + ecosystem_id: ecosystem_id(), + display_name: location_hint.clone(), + source_kind: ExternalHookSourceKind::PluginFile, + scope, + location_hint, + health, + content_version, + diagnostics, + } +} + +fn entry( + source: &SourceKey, + native_event: String, + registration_id: &str, +) -> ExternalHookCatalogEntry { + let mapping = match native_event.as_str() { + "tool.execute.before" => Some(ExternalHookMapping { + hook_point: ExternalHookPoint::ToolBefore, + }), + "tool.execute.after" => Some(ExternalHookMapping { + hook_point: ExternalHookPoint::ToolAfter, + }), + _ => None, + }; + let stable_key = format!( + "opencode-hook:{}", + short_hash(format!("{}:{registration_id}:{native_event}", source.stable_key()).as_bytes()), + ); + ExternalHookCatalogEntry { + content_version: content_hash( + format!("static:{registration_id}:{native_event}:any:function").as_bytes(), + ), + stable_key, + source: source.clone(), + native_event, + matcher: ExternalHookMatcherSummary::Any, + handler_kind: ExternalHookHandlerKind::Function, + projection_status: if mapping.is_some() { + ExternalHookProjectionStatus::Mapped + } else { + ExternalHookProjectionStatus::NativeOnly + }, + native_activation: ExternalHookNativeActivation::Unknown, + mapping, + } +} + +fn opaque_entry( + source: &SourceKey, + native_event: String, + registration_id: &str, + matcher: ExternalHookMatcherSummary, +) -> ExternalHookCatalogEntry { + let matcher_version = match &matcher { + ExternalHookMatcherSummary::Dynamic => "dynamic", + _ => "unavailable", + }; + ExternalHookCatalogEntry { + stable_key: format!( + "opencode-hook:{}", + short_hash( + format!( + "{}:{registration_id}:{native_event}:opaque", + source.stable_key() + ) + .as_bytes() + ) + ), + source: source.clone(), + native_event: native_event.clone(), + matcher, + handler_kind: ExternalHookHandlerKind::Function, + projection_status: ExternalHookProjectionStatus::Opaque, + native_activation: ExternalHookNativeActivation::Unknown, + mapping: None, + content_version: content_hash( + format!("opaque:{registration_id}:{native_event}:{matcher_version}").as_bytes(), + ), + } +} + +fn source_key(kind: &str, identity: &str) -> SourceKey { + SourceKey::new( + PROVIDER_ID, + format!("{kind}-{}", short_hash(identity.as_bytes())), + ) + .expect("hashed OpenCode Hook source key must be valid") +} + +fn ecosystem_id() -> EcosystemId { + EcosystemId::new(ECOSYSTEM_ID).expect("static ecosystem id must be valid") +} + +fn redacted_discovery_version( + discovery: &crate::source_adapter::StaticHookEventDiscovery, +) -> String { + let mut hasher = Sha256::new(); + for event in &discovery.events { + hasher.update(b"event:"); + hasher.update(event.registration_id.as_bytes()); + hasher.update([0]); + hasher.update(event.native_event.as_bytes()); + hasher.update([0]); + } + for event in &discovery.opaque_events { + hasher.update(b"opaque:"); + hasher.update(event.registration_id.as_bytes()); + hasher.update([0]); + hasher.update(event.native_event.as_bytes()); + hasher.update([0]); + } + for registration_id in &discovery.dynamic_registrations { + hasher.update(b"dynamic:"); + hasher.update(registration_id.as_bytes()); + hasher.update([0]); + } + format!("sha256:{}", hex::encode(hasher.finalize())) +} + +fn content_hash(value: &[u8]) -> String { + format!("sha256:{}", hex::encode(Sha256::digest(value))) +} + +fn short_hash(value: &[u8]) -> String { + hex::encode(Sha256::digest(value))[..24].to_string() +} + +fn hook_warning(code: &str, message: &str, source: Option) -> ExternalSourceDiagnostic { + ExternalSourceDiagnostic::warning(code, message, source) + .with_asset_kind(ExternalSourceAssetKind::Hook) +} + +fn hook_info(code: &str, message: &str, source: Option) -> ExternalSourceDiagnostic { + ExternalSourceDiagnostic { + severity: ExternalSourceDiagnosticSeverity::Info, + asset_kind: ExternalSourceAssetKind::Hook, + code: code.to_string(), + message: message.to_string(), + source, + } +} + +fn environment_truthy(name: &str) -> bool { + std::env::var(name) + .map(|value| matches!(value.to_ascii_lowercase().as_str(), "1" | "true" | "yes")) + .unwrap_or(false) +} diff --git a/src/crates/adapters/opencode-adapter/src/lib.rs b/src/crates/adapters/opencode-adapter/src/lib.rs index 1661e0a898..738c08a609 100644 --- a/src/crates/adapters/opencode-adapter/src/lib.rs +++ b/src/crates/adapters/opencode-adapter/src/lib.rs @@ -9,12 +9,14 @@ mod agent_source; mod command_source; mod hook_contributions; +mod hook_source; mod mcp_source; mod source_adapter; mod tool_source; pub use agent_source::{OpenCodeSubagentProvider, OpenCodeSubagentProviderOptions}; pub use command_source::{OpenCodeCommandProvider, OpenCodeCommandProviderOptions}; +pub use hook_source::{OpenCodeHookProvider, OpenCodeHookProviderOptions}; pub use mcp_source::{OpenCodeMcpProvider, OpenCodeMcpProviderOptions}; pub use source_adapter::load_opencode_package_adapter; pub use tool_source::{OpenCodeToolProvider, OpenCodeToolProviderOptions}; diff --git a/src/crates/adapters/opencode-adapter/src/source_adapter.rs b/src/crates/adapters/opencode-adapter/src/source_adapter.rs index 6e50d05d10..b9c00f3528 100644 --- a/src/crates/adapters/opencode-adapter/src/source_adapter.rs +++ b/src/crates/adapters/opencode-adapter/src/source_adapter.rs @@ -2025,6 +2025,253 @@ fn discover_exported_hooks( .unwrap_or_default()) } +pub(crate) struct StaticHookEvent { + pub registration_id: String, + pub native_event: String, +} + +pub(crate) struct StaticHookEventDiscovery { + pub events: Vec, + pub opaque_events: Vec, + pub dynamic_registrations: Vec, +} + +pub(crate) fn statically_discover_hook_events( + path: &Path, + source: &str, +) -> Result { + let allocator = Allocator::default(); + let source_type = SourceType::from_path(path).unwrap_or(SourceType::ts()); + // OXC already understands comments and regular-expression literals. A + // text-level comment pass can corrupt valid JavaScript such as `/https?:\/\//`. + let parsed = Parser::new(&allocator, source, source_type).parse(); + if !parsed.diagnostics.is_empty() { + return Err(OpenCodeHookProjectionError::ParseFailed + .as_str() + .to_string()); + } + let mut events = Vec::new(); + let mut opaque_events = Vec::new(); + let mut dynamic_registrations = Vec::new(); + let mut dynamic_seen = HashSet::new(); + let mut exported_declaration_seen = false; + for statement in &parsed.program.body { + if let Statement::ExportDefaultDeclaration(export) = statement { + exported_declaration_seen = true; + let has_runtime_value = match &export.declaration { + oxc_parse::ast::ast::ExportDefaultDeclarationKind::FunctionDeclaration( + function, + ) => !function.declare, + oxc_parse::ast::ast::ExportDefaultDeclarationKind::ClassDeclaration(class) => { + !class.declare + } + oxc_parse::ast::ast::ExportDefaultDeclarationKind::TSInterfaceDeclaration(_) => { + false + } + _ => true, + }; + if has_runtime_value { + push_dynamic_registration( + "default".to_string(), + &mut dynamic_registrations, + &mut dynamic_seen, + ); + } + continue; + } + if let Statement::ExportAllDeclaration(export) = statement { + exported_declaration_seen = true; + if matches!( + export.export_kind, + oxc_parse::ast::ast::ImportOrExportKind::Value + ) { + let registration_id = export + .exported + .as_ref() + .map(|name| name.name().to_string()) + .unwrap_or_else(|| format!("", export.source.value)); + push_dynamic_registration( + registration_id, + &mut dynamic_registrations, + &mut dynamic_seen, + ); + } + continue; + } + let Statement::ExportNamedDeclaration(export) = statement else { + continue; + }; + let Some(declaration) = &export.declaration else { + if !export.specifiers.is_empty() { + exported_declaration_seen = true; + if matches!( + export.export_kind, + oxc_parse::ast::ast::ImportOrExportKind::Value + ) { + for specifier in &export.specifiers { + if matches!( + specifier.export_kind, + oxc_parse::ast::ast::ImportOrExportKind::Value + ) { + push_dynamic_registration( + specifier.exported.name().to_string(), + &mut dynamic_registrations, + &mut dynamic_seen, + ); + } + } + } + } + continue; + }; + exported_declaration_seen = true; + if declaration.declare() { + continue; + } + let Declaration::VariableDeclaration(declaration) = declaration else { + let registration_id = match declaration { + Declaration::FunctionDeclaration(function) => { + function.id.as_ref().map(|id| id.name.to_string()) + } + Declaration::ClassDeclaration(class) => { + class.id.as_ref().map(|id| id.name.to_string()) + } + Declaration::TSEnumDeclaration(declaration) + if !declaration.declare && !declaration.r#const => + { + Some(declaration.id.name.to_string()) + } + _ => None, + }; + if let Some(registration_id) = registration_id { + push_dynamic_registration( + registration_id, + &mut dynamic_registrations, + &mut dynamic_seen, + ); + } + continue; + }; + for declarator in &declaration.declarations { + let is_simple_identifier = declarator.id.get_identifier_name().is_some(); + let mut registration_ids = declarator + .id + .get_binding_identifiers() + .into_iter() + .map(|identifier| identifier.name.to_string()) + .collect::>(); + if !is_simple_identifier { + if registration_ids.is_empty() { + registration_ids.push("".to_string()); + } + for registration_id in registration_ids { + push_dynamic_registration( + registration_id, + &mut dynamic_registrations, + &mut dynamic_seen, + ); + } + continue; + } + let registration_id = registration_ids + .pop() + .expect("identifier binding must contain its registration name"); + let Some(Expression::ArrowFunctionExpression(arrow)) = declarator + .init + .as_ref() + .map(Expression::get_inner_expression) + else { + push_dynamic_registration( + registration_id, + &mut dynamic_registrations, + &mut dynamic_seen, + ); + continue; + }; + let Some(object) = arrow_return_object(arrow) else { + push_dynamic_registration( + registration_id, + &mut dynamic_registrations, + &mut dynamic_seen, + ); + continue; + }; + if collect_static_hook_properties( + object, + ®istration_id, + &mut events, + &mut opaque_events, + ) { + push_dynamic_registration( + registration_id, + &mut dynamic_registrations, + &mut dynamic_seen, + ); + } + } + } + if !exported_declaration_seen { + push_dynamic_registration( + "".to_string(), + &mut dynamic_registrations, + &mut dynamic_seen, + ); + } + Ok(StaticHookEventDiscovery { + events, + opaque_events, + dynamic_registrations, + }) +} + +fn collect_static_hook_properties( + object: &ObjectExpression<'_>, + registration_id: &str, + events: &mut Vec, + opaque_events: &mut Vec, +) -> bool { + let mut seen = HashSet::new(); + let mut has_dynamic_registration = false; + for property in &object.properties { + let ObjectPropertyKind::ObjectProperty(property) = property else { + has_dynamic_registration = true; + continue; + }; + let Some(name) = property.key.static_name() else { + has_dynamic_registration = true; + continue; + }; + let name = name.into_owned(); + if name == CUSTOM_TOOL_EXTENSION_POINT || !seen.insert(name.clone()) { + continue; + } + if name.is_empty() || name.len() > 160 || name.chars().any(char::is_control) { + has_dynamic_registration = true; + } else if DISCOVERABLE_HOOK_PROPERTIES.contains(&name.as_str()) { + events.push(StaticHookEvent { + registration_id: registration_id.to_string(), + native_event: name, + }); + } else { + opaque_events.push(StaticHookEvent { + registration_id: registration_id.to_string(), + native_event: name, + }); + } + } + has_dynamic_registration +} + +fn push_dynamic_registration( + registration_id: String, + dynamic_registrations: &mut Vec, + seen: &mut HashSet, +) { + if seen.insert(registration_id.clone()) { + dynamic_registrations.push(registration_id); + } +} + fn exported_hook_object<'a>( statement: &'a Statement<'a>, export_name: &str, diff --git a/src/crates/adapters/opencode-adapter/tests/hook_source.rs b/src/crates/adapters/opencode-adapter/tests/hook_source.rs new file mode 100644 index 0000000000..edf838394d --- /dev/null +++ b/src/crates/adapters/opencode-adapter/tests/hook_source.rs @@ -0,0 +1,760 @@ +use bitfun_opencode_adapter::{OpenCodeHookProvider, OpenCodeHookProviderOptions}; +use bitfun_product_domains::external_hook_catalog::{ + ExternalHookHandlerKind, ExternalHookProjectionStatus, ExternalHookSourceProvider, +}; +use bitfun_product_domains::external_hook_contributions::ExternalHookPoint; +use bitfun_product_domains::external_sources::{ExecutionDomainId, ExternalSourceContext}; +use std::fs; +use tempfile::tempdir; + +fn context(workspace: &std::path::Path) -> ExternalSourceContext { + ExternalSourceContext { + workspace_root: Some(workspace.to_path_buf()), + execution_domain_id: ExecutionDomainId::new("local-user").unwrap(), + } +} + +#[test] +fn discovers_static_project_hooks_without_loading_the_plugin() { + let root = tempdir().unwrap(); + let user = root.path().join("user"); + let workspace = root.path().join("workspace"); + let plugins = workspace.join(".opencode/plugins"); + fs::create_dir_all(&plugins).unwrap(); + fs::create_dir_all(&user).unwrap(); + fs::write( + plugins.join("audit.ts"), + r#" +export const AuditPlugin = async () => ({ + "tool.execute.before": async () => {}, + "tool.execute.after": async () => {}, + "session.compacted": async () => {}, +}) +"#, + ) + .unwrap(); + + let provider = OpenCodeHookProvider::new(OpenCodeHookProviderOptions { + user_config_dir: user, + legacy_user_config_dir: None, + explicit_config_dir: None, + project_config_enabled: true, + project_root_override: Some(workspace.clone()), + }); + let snapshot = provider.discover(&context(&workspace)).unwrap(); + + assert_eq!(snapshot.sources.len(), 1); + assert_eq!(snapshot.entries.len(), 3); + assert!(snapshot + .entries + .iter() + .all(|entry| { entry.handler_kind == ExternalHookHandlerKind::Function })); + assert!(snapshot.entries.iter().any(|entry| { + entry.mapping.as_ref().map(|mapping| mapping.hook_point) + == Some(ExternalHookPoint::ToolAfter) + })); + assert!(snapshot.entries.iter().any(|entry| { + entry.mapping.as_ref().map(|mapping| mapping.hook_point) + == Some(ExternalHookPoint::ToolBefore) + })); + assert!(snapshot.entries.iter().any(|entry| { + entry.native_event == "session.compacted" + && entry.projection_status == ExternalHookProjectionStatus::Opaque + })); +} + +#[test] +fn discovers_hooks_from_every_named_plugin_export_in_module_order() { + let root = tempdir().unwrap(); + let user = root.path().join("user"); + let workspace = root.path().join("workspace"); + fs::create_dir_all(user.join("plugins")).unwrap(); + fs::create_dir_all(&workspace).unwrap(); + fs::write( + user.join("plugins/multiple.ts"), + r#" +export const First = async () => ({ "chat.message": async () => {} }) +export const Second = async () => ({ "tool.execute.after": async () => {} }) +"#, + ) + .unwrap(); + let provider = OpenCodeHookProvider::new(OpenCodeHookProviderOptions { + user_config_dir: user, + legacy_user_config_dir: None, + explicit_config_dir: None, + project_config_enabled: false, + project_root_override: Some(workspace.clone()), + }); + + let snapshot = provider.discover(&context(&workspace)).unwrap(); + + assert_eq!( + snapshot + .entries + .iter() + .map(|entry| entry.native_event.as_str()) + .collect::>(), + vec!["chat.message", "tool.execute.after"] + ); +} + +#[test] +fn preserves_each_binding_from_a_destructured_plugin_export() { + let root = tempdir().unwrap(); + let user = root.path().join("user"); + let workspace = root.path().join("workspace"); + fs::create_dir_all(user.join("plugins")).unwrap(); + fs::create_dir_all(&workspace).unwrap(); + fs::write( + user.join("plugins/destructured.ts"), + r#" +const plugins = { + First: async () => ({ "tool.execute.before": async () => {} }), + Second: async () => ({ "tool.execute.after": async () => {} }), +} +export const { First, Second } = plugins +"#, + ) + .unwrap(); + let provider = OpenCodeHookProvider::new(OpenCodeHookProviderOptions { + user_config_dir: user, + legacy_user_config_dir: None, + explicit_config_dir: None, + project_config_enabled: false, + project_root_override: Some(workspace.clone()), + }); + + let snapshot = provider.discover(&context(&workspace)).unwrap(); + + assert_eq!(snapshot.entries.len(), 2); + assert!(snapshot + .entries + .iter() + .all(|entry| entry.native_event == "")); + assert_ne!( + snapshot.entries[0].stable_key, + snapshot.entries[1].stable_key + ); +} + +#[test] +fn type_only_exports_do_not_create_dynamic_hook_entries() { + let root = tempdir().unwrap(); + let user = root.path().join("user"); + let workspace = root.path().join("workspace"); + fs::create_dir_all(user.join("plugins")).unwrap(); + fs::create_dir_all(&workspace).unwrap(); + fs::write( + user.join("plugins/types.ts"), + r#" +type PluginShape = { name: string } +export type { PluginShape } +"#, + ) + .unwrap(); + let provider = OpenCodeHookProvider::new(OpenCodeHookProviderOptions { + user_config_dir: user, + legacy_user_config_dir: None, + explicit_config_dir: None, + project_config_enabled: false, + project_root_override: Some(workspace.clone()), + }); + + let snapshot = provider.discover(&context(&workspace)).unwrap(); + + assert_eq!(snapshot.sources.len(), 1); + assert!(snapshot.entries.is_empty()); +} + +#[test] +fn type_only_export_all_and_ambient_exports_do_not_create_hook_entries() { + let root = tempdir().unwrap(); + let user = root.path().join("user"); + let workspace = root.path().join("workspace"); + fs::create_dir_all(user.join("plugins")).unwrap(); + fs::create_dir_all(&workspace).unwrap(); + fs::write( + user.join("plugins/ambient.ts"), + r#" +export type * from "./types" +export declare const Plugin: unknown +export declare function Factory(): unknown +export declare class PluginClass {} +"#, + ) + .unwrap(); + let provider = OpenCodeHookProvider::new(OpenCodeHookProviderOptions { + user_config_dir: user, + legacy_user_config_dir: None, + explicit_config_dir: None, + project_config_enabled: false, + project_root_override: Some(workspace.clone()), + }); + + let snapshot = provider.discover(&context(&workspace)).unwrap(); + + assert_eq!(snapshot.sources.len(), 1); + assert!(snapshot.entries.is_empty()); +} + +#[test] +fn declaration_files_are_not_discovered_as_runtime_plugins() { + let root = tempdir().unwrap(); + let user = root.path().join("user"); + let workspace = root.path().join("workspace"); + fs::create_dir_all(user.join("plugins")).unwrap(); + fs::create_dir_all(&workspace).unwrap(); + fs::write( + user.join("plugins/plugin.d.ts"), + r#" +export function Plugin(): unknown +export class PluginClass {} +"#, + ) + .unwrap(); + let provider = OpenCodeHookProvider::new(OpenCodeHookProviderOptions { + user_config_dir: user, + legacy_user_config_dir: None, + explicit_config_dir: None, + project_config_enabled: false, + project_root_override: Some(workspace.clone()), + }); + + let snapshot = provider.discover(&context(&workspace)).unwrap(); + + assert!(snapshot.sources.is_empty()); + assert!(snapshot.entries.is_empty()); +} + +#[test] +fn value_export_all_is_preserved_as_an_opaque_runtime_export() { + let root = tempdir().unwrap(); + let user = root.path().join("user"); + let workspace = root.path().join("workspace"); + fs::create_dir_all(user.join("plugins")).unwrap(); + fs::create_dir_all(&workspace).unwrap(); + fs::write( + user.join("plugins/reexport.ts"), + r#"export * from "./plugins""#, + ) + .unwrap(); + let provider = OpenCodeHookProvider::new(OpenCodeHookProviderOptions { + user_config_dir: user, + legacy_user_config_dir: None, + explicit_config_dir: None, + project_config_enabled: false, + project_root_override: Some(workspace.clone()), + }); + + let snapshot = provider.discover(&context(&workspace)).unwrap(); + + assert_eq!(snapshot.entries.len(), 1); + assert_eq!(snapshot.entries[0].native_event, ""); + assert_eq!( + snapshot.entries[0].projection_status, + ExternalHookProjectionStatus::Opaque + ); +} + +#[test] +fn preserves_same_event_registered_by_distinct_named_plugin_exports() { + let root = tempdir().unwrap(); + let user = root.path().join("user"); + let workspace = root.path().join("workspace"); + fs::create_dir_all(user.join("plugins")).unwrap(); + fs::create_dir_all(&workspace).unwrap(); + fs::write( + user.join("plugins/multiple.ts"), + r#" +export const First = async () => ({ "tool.execute.before": async () => {} }) +export const Second = async () => ({ "tool.execute.before": async () => {} }) +"#, + ) + .unwrap(); + let provider = OpenCodeHookProvider::new(OpenCodeHookProviderOptions { + user_config_dir: user, + legacy_user_config_dir: None, + explicit_config_dir: None, + project_config_enabled: false, + project_root_override: Some(workspace.clone()), + }); + + let snapshot = provider.discover(&context(&workspace)).unwrap(); + + assert_eq!(snapshot.entries.len(), 2); + assert!(snapshot + .entries + .iter() + .all(|entry| entry.native_event == "tool.execute.before")); + assert_ne!( + snapshot.entries[0].stable_key, + snapshot.entries[1].stable_key + ); +} + +#[test] +fn preserves_each_opaque_named_function_and_specifier_export() { + let root = tempdir().unwrap(); + let user = root.path().join("user"); + let workspace = root.path().join("workspace"); + fs::create_dir_all(user.join("plugins")).unwrap(); + fs::create_dir_all(&workspace).unwrap(); + fs::write( + user.join("plugins/functions.ts"), + r#" +export async function First() { return { "tool.execute.before": async () => {} } } +export async function Second() { return { "tool.execute.after": async () => {} } } +"#, + ) + .unwrap(); + fs::write( + user.join("plugins/specifiers.ts"), + r#" +const Third = async () => ({ "tool.execute.before": async () => {} }) +const Fourth = async () => ({ "tool.execute.after": async () => {} }) +export { Third, Fourth } +"#, + ) + .unwrap(); + let provider = OpenCodeHookProvider::new(OpenCodeHookProviderOptions { + user_config_dir: user, + legacy_user_config_dir: None, + explicit_config_dir: None, + project_config_enabled: false, + project_root_override: Some(workspace.clone()), + }); + + let snapshot = provider.discover(&context(&workspace)).unwrap(); + + assert_eq!(snapshot.entries.len(), 4); + assert!(snapshot + .entries + .iter() + .all(|entry| entry.native_event == "")); + assert_eq!( + snapshot + .entries + .iter() + .map(|entry| entry.stable_key.as_str()) + .collect::>() + .len(), + 4 + ); +} + +#[test] +fn changing_only_plugin_handler_bodies_does_not_change_catalog_versions() { + let root = tempdir().unwrap(); + let user = root.path().join("user"); + let workspace = root.path().join("workspace"); + fs::create_dir_all(user.join("plugins")).unwrap(); + fs::create_dir_all(&workspace).unwrap(); + let plugin = user.join("plugins/audit.ts"); + let write = |secret: &str| { + fs::write( + &plugin, + format!(r#"export const Audit = async () => ({{ "tool.execute.before": async () => {{ const token = "{secret}" }} }})"#), + ) + .unwrap(); + }; + let provider = OpenCodeHookProvider::new(OpenCodeHookProviderOptions { + user_config_dir: user, + legacy_user_config_dir: None, + explicit_config_dir: None, + project_config_enabled: false, + project_root_override: Some(workspace.clone()), + }); + write("token-one"); + let first = provider.discover(&context(&workspace)).unwrap(); + write("token-two"); + let second = provider.discover(&context(&workspace)).unwrap(); + + assert_eq!( + first.sources[0].content_version, + second.sources[0].content_version + ); + assert_eq!( + first.entries[0].content_version, + second.entries[0].content_version + ); +} + +#[test] +fn npm_packages_are_declared_only_and_malformed_plugins_are_isolated() { + let root = tempdir().unwrap(); + let user = root.path().join("user"); + let workspace = root.path().join("workspace"); + fs::create_dir_all(user.join("plugins")).unwrap(); + fs::create_dir_all(&workspace).unwrap(); + fs::write( + user.join("config.json"), + r#"{"plugin":["@example/audit-plugin"]}"#, + ) + .unwrap(); + fs::write(user.join("plugins/broken.ts"), "export const =").unwrap(); + + let provider = OpenCodeHookProvider::new(OpenCodeHookProviderOptions { + user_config_dir: user, + legacy_user_config_dir: None, + explicit_config_dir: None, + project_config_enabled: true, + project_root_override: Some(workspace.clone()), + }); + let snapshot = provider.discover(&context(&workspace)).unwrap(); + + assert!(snapshot.entries.is_empty()); + assert_eq!(snapshot.sources.len(), 2); + assert!(snapshot + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "opencode.hook.package_declared_only")); + assert!(snapshot + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "opencode.hook.plugin_parse_failed")); +} + +#[test] +fn current_jsonc_tuple_declarations_and_both_local_plugin_directories_are_visible() { + let root = tempdir().unwrap(); + let user = root.path().join("user"); + let workspace = root.path().join("workspace"); + fs::create_dir_all(user.join("plugin")).unwrap(); + fs::create_dir_all(user.join("plugins")).unwrap(); + fs::create_dir_all(&workspace).unwrap(); + fs::write( + user.join("opencode.jsonc"), + r#"{ + // OpenCode accepts JSONC and tuple plugin specs. + "plugin": [["@example/audit-plugin", {"mode":"safe"}],], + }"#, + ) + .unwrap(); + for directory in ["plugin", "plugins"] { + fs::write( + user.join(directory).join(format!("{directory}.ts")), + r#"export const Audit = async () => ({ "tool.execute.before": async () => {} })"#, + ) + .unwrap(); + } + + let provider = OpenCodeHookProvider::new(OpenCodeHookProviderOptions { + user_config_dir: user, + legacy_user_config_dir: None, + explicit_config_dir: None, + project_config_enabled: false, + project_root_override: Some(workspace.clone()), + }); + let snapshot = provider.discover(&context(&workspace)).unwrap(); + + assert_eq!(snapshot.entries.len(), 2); + assert_eq!( + snapshot + .diagnostics + .iter() + .filter(|diagnostic| diagnostic.code == "opencode.hook.package_declared_only") + .count(), + 1 + ); +} + +#[test] +fn project_root_opencode_config_declares_plugins_without_scanning_root_plugin_dirs() { + let root = tempdir().unwrap(); + let user = root.path().join("user"); + let workspace = root.path().join("workspace"); + fs::create_dir_all(user.clone()).unwrap(); + fs::create_dir_all(workspace.join("plugins")).unwrap(); + fs::write( + workspace.join("opencode.json"), + r#"{"plugin":["@example/project-plugin"]}"#, + ) + .unwrap(); + fs::write( + workspace.join("plugins/not-an-opencode-plugin.ts"), + r#"export const WrongRoot = async () => ({ "tool.execute.before": async () => {} })"#, + ) + .unwrap(); + + let provider = OpenCodeHookProvider::new(OpenCodeHookProviderOptions { + user_config_dir: user, + legacy_user_config_dir: None, + explicit_config_dir: None, + project_config_enabled: true, + project_root_override: Some(workspace.clone()), + }); + let snapshot = provider.discover(&context(&workspace)).unwrap(); + + assert!(snapshot.entries.is_empty()); + assert_eq!(snapshot.sources.len(), 1); + assert_eq!( + snapshot.sources[0].location_hint, + "OpenCode project configuration/opencode.json" + ); +} + +#[test] +fn computed_hook_registration_is_reported_as_opaque_instead_of_guessed() { + let root = tempdir().unwrap(); + let user = root.path().join("user"); + let workspace = root.path().join("workspace"); + fs::create_dir_all(user.join("plugins")).unwrap(); + fs::create_dir_all(&workspace).unwrap(); + fs::write( + user.join("plugins/dynamic.ts"), + r#" +const eventName = "tool.execute.before" +export const Dynamic = async () => ({ [eventName]: async () => {} }) +"#, + ) + .unwrap(); + + let provider = OpenCodeHookProvider::new(OpenCodeHookProviderOptions { + user_config_dir: user, + legacy_user_config_dir: None, + explicit_config_dir: None, + project_config_enabled: false, + project_root_override: Some(workspace.clone()), + }); + let snapshot = provider.discover(&context(&workspace)).unwrap(); + + assert_eq!(snapshot.entries.len(), 1); + assert_eq!( + snapshot.entries[0].projection_status, + ExternalHookProjectionStatus::Opaque + ); + assert_eq!(snapshot.entries[0].native_event, ""); + assert!(snapshot + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "opencode.hook.registration_opaque")); +} + +#[test] +fn total_hook_entries_are_bounded_across_many_plugins() { + let root = tempdir().unwrap(); + let user = root.path().join("user"); + let workspace = root.path().join("workspace"); + let plugins = user.join("plugins"); + fs::create_dir_all(&plugins).unwrap(); + fs::create_dir_all(&workspace).unwrap(); + let source = r#" +export const Many = async () => ({ + auth: async () => {}, + "chat.headers": async () => {}, + "chat.message": async () => {}, + "chat.params": async () => {}, + "command.execute.before": async () => {}, + config: async () => {}, + dispose: async () => {}, + event: async () => {}, + "experimental.chat.messages.transform": async () => {}, + "experimental.chat.system.transform": async () => {}, + "experimental.compaction.autocontinue": async () => {}, + "experimental.provider.small_model": async () => {}, + "experimental.session.compacting": async () => {}, + "experimental.text.complete": async () => {}, + "permission.ask": async () => {}, + provider: async () => {}, + "shell.env": async () => {}, + "tool.execute.after": async () => {}, + "tool.execute.before": async () => {}, + "tool.definition": async () => {}, +}) +"#; + for index in 0..103 { + fs::write(plugins.join(format!("many-{index:03}.ts")), source).unwrap(); + } + + let provider = OpenCodeHookProvider::new(OpenCodeHookProviderOptions { + user_config_dir: user, + legacy_user_config_dir: None, + explicit_config_dir: None, + project_config_enabled: false, + project_root_override: Some(workspace.clone()), + }); + let snapshot = provider.discover(&context(&workspace)).unwrap(); + + assert_eq!(snapshot.entries.len(), 2048); + assert_eq!( + snapshot + .diagnostics + .iter() + .filter(|diagnostic| diagnostic.code == "opencode.hook.entry_limit") + .count(), + 1 + ); +} + +#[test] +fn regex_literals_with_comment_markers_do_not_corrupt_static_parsing() { + let root = tempdir().unwrap(); + let user = root.path().join("user"); + let workspace = root.path().join("workspace"); + fs::create_dir_all(user.join("plugins")).unwrap(); + fs::create_dir_all(&workspace).unwrap(); + fs::write( + user.join("plugins/regex.ts"), + r#" +const endpoint = /https?:\/\// +export const Audit = async () => ({ "tool.execute.before": async () => endpoint }) +"#, + ) + .unwrap(); + let provider = OpenCodeHookProvider::new(OpenCodeHookProviderOptions { + user_config_dir: user, + legacy_user_config_dir: None, + explicit_config_dir: None, + project_config_enabled: false, + project_root_override: Some(workspace.clone()), + }); + + let snapshot = provider.discover(&context(&workspace)).unwrap(); + + assert_eq!(snapshot.entries.len(), 1); + assert_eq!(snapshot.entries[0].native_event, "tool.execute.before"); + assert!(!snapshot + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "opencode.hook.plugin_parse_failed")); +} + +#[test] +fn inserting_another_static_event_does_not_churn_existing_entry_identity() { + let root = tempdir().unwrap(); + let user = root.path().join("user"); + let workspace = root.path().join("workspace"); + fs::create_dir_all(user.join("plugins")).unwrap(); + fs::create_dir_all(&workspace).unwrap(); + let plugin = user.join("plugins/audit.ts"); + let provider = OpenCodeHookProvider::new(OpenCodeHookProviderOptions { + user_config_dir: user, + legacy_user_config_dir: None, + explicit_config_dir: None, + project_config_enabled: false, + project_root_override: Some(workspace.clone()), + }); + fs::write( + &plugin, + r#"export const Audit = async () => ({ "tool.execute.before": async () => {} })"#, + ) + .unwrap(); + let first = provider.discover(&context(&workspace)).unwrap(); + fs::write( + &plugin, + r#"export const Audit = async () => ({ "chat.message": async () => {}, "tool.execute.before": async () => {} })"#, + ) + .unwrap(); + let second = provider.discover(&context(&workspace)).unwrap(); + let first_entry = &first.entries[0]; + let second_entry = second + .entries + .iter() + .find(|entry| entry.native_event == "tool.execute.before") + .unwrap(); + + assert_eq!(first_entry.stable_key, second_entry.stable_key); + assert_eq!(first_entry.content_version, second_entry.content_version); +} + +#[test] +fn inserting_a_static_event_does_not_churn_opaque_entry_identity() { + let root = tempdir().unwrap(); + let user = root.path().join("user"); + let workspace = root.path().join("workspace"); + fs::create_dir_all(user.join("plugins")).unwrap(); + fs::create_dir_all(&workspace).unwrap(); + let plugin = user.join("plugins/audit.ts"); + let provider = OpenCodeHookProvider::new(OpenCodeHookProviderOptions { + user_config_dir: user, + legacy_user_config_dir: None, + explicit_config_dir: None, + project_config_enabled: false, + project_root_override: Some(workspace.clone()), + }); + fs::write( + &plugin, + r#"export const Audit = async () => ({ "session.compacted": async () => {} })"#, + ) + .unwrap(); + let first = provider.discover(&context(&workspace)).unwrap(); + fs::write( + &plugin, + r#"export const Audit = async () => ({ "tool.execute.before": async () => {}, "session.compacted": async () => {} })"#, + ) + .unwrap(); + let second = provider.discover(&context(&workspace)).unwrap(); + let first_entry = &first.entries[0]; + let second_entry = second + .entries + .iter() + .find(|entry| entry.native_event == "session.compacted") + .unwrap(); + + assert_eq!(first_entry.stable_key, second_entry.stable_key); + assert_eq!(first_entry.content_version, second_entry.content_version); +} + +#[test] +fn project_hook_discovery_includes_bounded_ancestor_directories() { + let root = tempdir().unwrap(); + let user = root.path().join("user"); + let project = root.path().join("project"); + let workspace = project.join("packages/app"); + for directory in [ + project.join(".opencode/plugins"), + workspace.join(".opencode/plugins"), + ] { + fs::create_dir_all(&directory).unwrap(); + fs::write( + directory.join("audit.ts"), + r#"export const Audit = async () => ({ "tool.execute.before": async () => {} })"#, + ) + .unwrap(); + } + fs::create_dir_all(&user).unwrap(); + let provider = OpenCodeHookProvider::new(OpenCodeHookProviderOptions { + user_config_dir: user, + legacy_user_config_dir: None, + explicit_config_dir: None, + project_config_enabled: true, + project_root_override: Some(project), + }); + + let snapshot = provider.discover(&context(&workspace)).unwrap(); + + assert_eq!(snapshot.entries.len(), 2); + assert_eq!(snapshot.sources.len(), 2); +} + +#[cfg(unix)] +#[test] +fn plugin_symlinks_follow_native_file_discovery() { + use std::os::unix::fs::symlink; + + let root = tempdir().unwrap(); + let user = root.path().join("user"); + let workspace = root.path().join("workspace"); + let plugins = user.join("plugins"); + fs::create_dir_all(&plugins).unwrap(); + fs::create_dir_all(&workspace).unwrap(); + let target = root.path().join("shared.ts"); + fs::write( + &target, + r#"export const Audit = async () => ({ "tool.execute.after": async () => {} })"#, + ) + .unwrap(); + symlink(&target, plugins.join("linked.ts")).unwrap(); + let provider = OpenCodeHookProvider::new(OpenCodeHookProviderOptions { + user_config_dir: user, + legacy_user_config_dir: None, + explicit_config_dir: None, + project_config_enabled: false, + project_root_override: Some(workspace.clone()), + }); + + let snapshot = provider.discover(&context(&workspace)).unwrap(); + + assert_eq!(snapshot.entries.len(), 1); + assert_eq!(snapshot.entries[0].native_event, "tool.execute.after"); +} diff --git a/src/crates/adapters/static-hook-support/Cargo.toml b/src/crates/adapters/static-hook-support/Cargo.toml new file mode 100644 index 0000000000..6befdb2ae7 --- /dev/null +++ b/src/crates/adapters/static-hook-support/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "bitfun-static-hook-support" +version.workspace = true +authors.workspace = true +edition.workspace = true +description = "Shared redacting parser support for static external Hook adapters" + +[lib] +name = "bitfun_static_hook_support" +crate-type = ["rlib"] + +[dependencies] +bitfun-product-domains = { path = "../../contracts/product-domains", default-features = false, features = ["external-sources"] } +serde_json = { workspace = true } +toml = { workspace = true } +hex = { workspace = true } +sha2 = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } + +[lints] +workspace = true diff --git a/src/crates/adapters/static-hook-support/src/lib.rs b/src/crates/adapters/static-hook-support/src/lib.rs new file mode 100644 index 0000000000..08a6b569c2 --- /dev/null +++ b/src/crates/adapters/static-hook-support/src/lib.rs @@ -0,0 +1,319 @@ +//! Shared, runtime-free parser support for ecosystem Hook source adapters. + +use bitfun_product_domains::external_hook_catalog::{ + ExternalHookHandlerKind, ExternalHookMatcherSummary, +}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::io::Read; +use std::path::Path; + +const MAX_MATCHER_BYTES: usize = 512; +const MAX_EVENT_NAME_BYTES: usize = 160; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BoundedFileRead { + Content(Vec), + TooLarge, +} + +/// Reads at most `max_bytes + 1` bytes so a file changed between metadata and +/// read cannot cause an unbounded allocation. +pub fn read_bounded_file(path: &Path, max_bytes: usize) -> std::io::Result { + let file = std::fs::File::open(path)?; + let read_limit = max_bytes.saturating_add(1) as u64; + let mut bytes = Vec::with_capacity(max_bytes.min(64 * 1024).saturating_add(1)); + file.take(read_limit).read_to_end(&mut bytes)?; + if bytes.len() > max_bytes { + Ok(BoundedFileRead::TooLarge) + } else { + Ok(BoundedFileRead::Content(bytes)) + } +} + +/// Distinguishes an absent path from metadata failures. Static adapters may +/// ignore `NotFound`, but permission and transient filesystem failures must be +/// surfaced so the coordinator can retain the last valid snapshot as stale. +pub fn regular_file_exists(path: &Path) -> std::io::Result { + match std::fs::metadata(path) { + Ok(metadata) => Ok(metadata.is_file()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error), + } +} + +/// Returns the bounded project path chain from the outer project boundary to +/// the selected workspace directory. An invalid boundary fails closed to the +/// workspace itself so adapters never walk arbitrary filesystem ancestors. +pub fn bounded_project_ancestors( + workspace_root: &Path, + project_boundary: &Path, + max_depth: usize, +) -> Vec { + if max_depth == 0 || !workspace_root.starts_with(project_boundary) { + return vec![workspace_root.to_path_buf()]; + } + let mut roots = Vec::new(); + let mut current = Some(workspace_root); + while let Some(path) = current { + if !path.starts_with(project_boundary) || roots.len() == max_depth { + break; + } + roots.push(path.to_path_buf()); + if path == project_boundary { + roots.reverse(); + return roots; + } + current = path.parent(); + } + vec![workspace_root.to_path_buf()] +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StaticHookDocumentFormat { + Json, + Toml, +} + +#[derive(Debug, Clone, Copy)] +pub struct StaticHookHandlerRule { + pub native_type: &'static str, + pub handler_kind: ExternalHookHandlerKind, + pub required_string_fields: &'static [&'static str], +} + +impl StaticHookHandlerRule { + pub const fn new( + native_type: &'static str, + handler_kind: ExternalHookHandlerKind, + required_string_fields: &'static [&'static str], + ) -> Self { + Self { + native_type, + handler_kind, + required_string_fields, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum StaticHookParseIssue { + DocumentInvalid, + EventNameInvalid, + EventInvalid, + GroupInvalid, + HandlerInvalid, + HandlerLimit, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StaticHookHandlerFact { + pub native_event: String, + pub matcher: ExternalHookMatcherSummary, + pub handler_kind: ExternalHookHandlerKind, + pub group_index: usize, + pub handler_index: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct StaticHookParseResult { + pub handlers: Vec, + pub issues: Vec, + pub all_disabled: bool, + pub inspected_handlers: usize, +} + +/// Fingerprints only facts that the catalog already exposes. Handler bodies, +/// command arguments, request data, environment variables, and credentials +/// never contribute to the externally visible version. +pub fn redacted_parse_content_version(result: &StaticHookParseResult) -> String { + let mut hasher = Sha256::new(); + hasher.update(if result.all_disabled { + b"disabled".as_slice() + } else { + b"unknown".as_slice() + }); + for handler in &result.handlers { + hasher.update([0]); + hasher.update(handler.native_event.as_bytes()); + hasher.update([0]); + match &handler.matcher { + ExternalHookMatcherSummary::Any => hasher.update(b"any"), + ExternalHookMatcherSummary::Pattern { display } => { + hasher.update(b"pattern:"); + hasher.update(display.as_bytes()); + } + ExternalHookMatcherSummary::Dynamic => hasher.update(b"dynamic"), + ExternalHookMatcherSummary::Unavailable => hasher.update(b"unavailable"), + _ => hasher.update(b"unknown_matcher"), + } + hasher.update(format!( + ":{:?}:{}:{}", + handler.handler_kind, handler.group_index, handler.handler_index + )); + } + for issue in &result.issues { + hasher.update(format!(":issue:{issue:?}")); + } + format!("sha256:{}", hex::encode(hasher.finalize())) +} + +/// Parses only Hook structure and returns redacted facts. Handler-specific +/// values are checked for presence but never copied into the result. +pub fn parse_hook_document( + bytes: &[u8], + format: StaticHookDocumentFormat, + rules: &[StaticHookHandlerRule], + max_handlers: usize, +) -> StaticHookParseResult { + let parsed = match format { + StaticHookDocumentFormat::Json => serde_json::from_slice::(bytes).ok(), + StaticHookDocumentFormat::Toml => std::str::from_utf8(bytes) + .ok() + .and_then(|source| toml::from_str::(source).ok()) + .and_then(|value| serde_json::to_value(value).ok()), + }; + let Some(Value::Object(root)) = parsed else { + return StaticHookParseResult { + issues: vec![StaticHookParseIssue::DocumentInvalid], + ..StaticHookParseResult::default() + }; + }; + + // This is only the Claude-compatible document flag. Other ecosystem + // adapters ignore it; static discovery does not evaluate Codex activation. + let all_disabled = root + .get("disableAllHooks") + .and_then(Value::as_bool) + .unwrap_or(false); + let mut result = StaticHookParseResult { + all_disabled, + ..StaticHookParseResult::default() + }; + let Some(Value::Object(events)) = root.get("hooks") else { + return result; + }; + + let mut event_names = events + .keys() + .filter(|name| name.as_str() != "state") + .cloned() + .collect::>(); + event_names.sort(); + 'events: for native_event in event_names { + if native_event.is_empty() + || native_event.len() > MAX_EVENT_NAME_BYTES + || native_event.chars().any(char::is_control) + { + record_issue(&mut result, StaticHookParseIssue::EventNameInvalid); + continue; + } + let Some(groups) = events.get(&native_event).and_then(Value::as_array) else { + record_issue(&mut result, StaticHookParseIssue::EventInvalid); + continue; + }; + for (group_index, group) in groups.iter().enumerate() { + let Some(group) = group.as_object() else { + record_issue(&mut result, StaticHookParseIssue::GroupInvalid); + continue; + }; + let matcher = matcher_summary(group.get("matcher")); + let Some(handlers) = group.get("hooks").and_then(Value::as_array) else { + record_issue(&mut result, StaticHookParseIssue::GroupInvalid); + continue; + }; + for (handler_index, handler) in handlers.iter().enumerate() { + if result.inspected_handlers >= max_handlers { + record_issue(&mut result, StaticHookParseIssue::HandlerLimit); + break 'events; + } + result.inspected_handlers += 1; + let Some(handler_kind) = parse_handler_kind(handler, rules) else { + record_issue(&mut result, StaticHookParseIssue::HandlerInvalid); + continue; + }; + result.handlers.push(StaticHookHandlerFact { + native_event: native_event.clone(), + matcher: matcher.clone(), + handler_kind, + group_index, + handler_index, + }); + } + } + } + result +} + +fn record_issue(result: &mut StaticHookParseResult, issue: StaticHookParseIssue) { + if !result.issues.contains(&issue) { + result.issues.push(issue); + } +} + +fn parse_handler_kind( + value: &Value, + rules: &[StaticHookHandlerRule], +) -> Option { + let object = value.as_object()?; + let native_type = object.get("type")?.as_str()?; + let rule = rules.iter().find(|rule| rule.native_type == native_type)?; + rule.required_string_fields + .iter() + .all(|field| { + object + .get(*field) + .and_then(Value::as_str) + .is_some_and(|value| !value.is_empty()) + }) + .then_some(rule.handler_kind) +} + +fn matcher_summary(value: Option<&Value>) -> ExternalHookMatcherSummary { + match value { + None => ExternalHookMatcherSummary::Any, + Some(Value::String(value)) if value.is_empty() => ExternalHookMatcherSummary::Any, + Some(Value::String(value)) + if value.len() <= MAX_MATCHER_BYTES && !value.chars().any(char::is_control) => + { + ExternalHookMatcherSummary::Pattern { + display: value.to_string(), + } + } + Some(_) => ExternalHookMatcherSummary::Unavailable, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn project_ancestors_are_bounded_and_returned_outer_to_inner() { + let boundary = Path::new("/repo"); + let workspace = Path::new("/repo/packages/app"); + assert_eq!( + bounded_project_ancestors(workspace, boundary, 8), + vec![ + std::path::PathBuf::from("/repo"), + std::path::PathBuf::from("/repo/packages"), + std::path::PathBuf::from("/repo/packages/app"), + ] + ); + assert_eq!( + bounded_project_ancestors(workspace, Path::new("/other"), 8), + vec![workspace.to_path_buf()] + ); + } + + #[test] + fn shared_parser_does_not_interpret_codex_feature_flags() { + let result = parse_hook_document( + br#"{"features":{"hooks":false}}"#, + StaticHookDocumentFormat::Json, + &[], + 1, + ); + assert!(!result.all_disabled); + } +} diff --git a/src/crates/adapters/static-hook-support/tests/parser.rs b/src/crates/adapters/static-hook-support/tests/parser.rs new file mode 100644 index 0000000000..54970aa35f --- /dev/null +++ b/src/crates/adapters/static-hook-support/tests/parser.rs @@ -0,0 +1,187 @@ +use bitfun_product_domains::external_hook_catalog::{ + ExternalHookHandlerKind, ExternalHookMatcherSummary, +}; +use bitfun_static_hook_support::{ + parse_hook_document, read_bounded_file, redacted_parse_content_version, regular_file_exists, + BoundedFileRead, StaticHookDocumentFormat, StaticHookHandlerRule, StaticHookParseIssue, +}; +use std::fs; + +const RULES: &[StaticHookHandlerRule] = &[ + StaticHookHandlerRule::new("command", ExternalHookHandlerKind::Command, &["command"]), + StaticHookHandlerRule::new("prompt", ExternalHookHandlerKind::Prompt, &[]), +]; + +#[test] +fn parses_json_to_redacted_handler_facts() { + let parsed = parse_hook_document( + br#"{"disableAllHooks":true,"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"secret --token abc"}]}]}}"#, + StaticHookDocumentFormat::Json, + RULES, + 16, + ); + + assert!(parsed.all_disabled); + assert_eq!(parsed.handlers.len(), 1); + assert_eq!(parsed.handlers[0].native_event, "PreToolUse"); + assert_eq!( + parsed.handlers[0].handler_kind, + ExternalHookHandlerKind::Command + ); + assert!(!format!("{parsed:?}").contains("secret")); + assert!(parsed.issues.is_empty()); +} + +#[test] +fn parses_toml_and_reports_invalid_handlers_without_losing_valid_ones() { + let parsed = parse_hook_document( + br#" +[[hooks.PostToolUse]] +matcher = "Edit" + +[[hooks.PostToolUse.hooks]] +type = "http" +url = "https://secret.example" + +[[hooks.PostToolUse.hooks]] +type = "prompt" +prompt = "private" +"#, + StaticHookDocumentFormat::Toml, + RULES, + 16, + ); + + assert_eq!(parsed.handlers.len(), 1); + assert_eq!( + parsed.handlers[0].handler_kind, + ExternalHookHandlerKind::Prompt + ); + assert_eq!(parsed.issues, vec![StaticHookParseIssue::HandlerInvalid]); + assert!(!format!("{parsed:?}").contains("secret.example")); + assert!(!format!("{parsed:?}").contains("private")); +} + +#[test] +fn handler_limit_is_global_and_deterministic() { + let parsed = parse_hook_document( + br#"{"hooks":{"PreToolUse":[{"hooks":[{"type":"prompt"},{"type":"prompt"}]}]}}"#, + StaticHookDocumentFormat::Json, + RULES, + 1, + ); + assert_eq!(parsed.handlers.len(), 1); + assert_eq!(parsed.issues, vec![StaticHookParseIssue::HandlerLimit]); +} + +#[test] +fn invalid_handlers_also_consume_the_inspection_budget() { + let parsed = parse_hook_document( + br#"{"hooks":{"PreToolUse":[{"hooks":[{"type":"unknown","value":"ignored"},{"type":"prompt"}]}]}}"#, + StaticHookDocumentFormat::Json, + RULES, + 1, + ); + assert!(parsed.handlers.is_empty()); + assert_eq!( + parsed.issues, + vec![ + StaticHookParseIssue::HandlerInvalid, + StaticHookParseIssue::HandlerLimit, + ] + ); +} + +#[test] +fn invalid_event_names_are_omitted_without_invalidating_other_events() { + let long_event = "x".repeat(161); + let input = format!( + r#"{{"hooks":{{"{long_event}":[{{"hooks":[{{"type":"prompt"}}]}}],"PreToolUse":[{{"hooks":[{{"type":"prompt"}}]}}]}}}}"#, + ); + let parsed = parse_hook_document(input.as_bytes(), StaticHookDocumentFormat::Json, RULES, 16); + + assert_eq!(parsed.handlers.len(), 1); + assert_eq!(parsed.handlers[0].native_event, "PreToolUse"); + assert_eq!(parsed.issues, vec![StaticHookParseIssue::EventNameInvalid]); +} + +#[test] +fn bounded_file_reads_never_return_content_past_the_limit() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("hooks.json"); + fs::write(&path, b"12345").unwrap(); + + assert_eq!( + read_bounded_file(&path, 4).unwrap(), + BoundedFileRead::TooLarge + ); + assert_eq!( + read_bounded_file(&path, 5).unwrap(), + BoundedFileRead::Content(b"12345".to_vec()) + ); +} + +#[test] +fn absent_files_are_distinct_from_existing_non_files() { + let root = tempfile::tempdir().unwrap(); + assert!(!regular_file_exists(&root.path().join("missing.json")).unwrap()); + assert!(!regular_file_exists(root.path()).unwrap()); + let file = root.path().join("hooks.json"); + fs::write(&file, "{}").unwrap(); + assert!(regular_file_exists(&file).unwrap()); +} + +#[test] +fn redacted_versions_ignore_handler_bodies() { + let parse = |command: &str| { + parse_hook_document( + format!(r#"{{"hooks":{{"PreToolUse":[{{"hooks":[{{"type":"command","command":"{command}"}}]}}]}}}}"#).as_bytes(), + StaticHookDocumentFormat::Json, + RULES, + 8, + ) + }; + assert_eq!( + redacted_parse_content_version(&parse("token-one")), + redacted_parse_content_version(&parse("token-two")) + ); +} + +#[test] +fn malformed_matchers_are_not_reported_as_matching_everything() { + let parsed = parse_hook_document( + br#"{"hooks":{"PreToolUse":[{"matcher":{"tool":"Bash"},"hooks":[{"type":"command","command":"safe"}]}]}}"#, + StaticHookDocumentFormat::Json, + &[StaticHookHandlerRule::new( + "command", + ExternalHookHandlerKind::Command, + &["command"], + )], + 8, + ); + + assert_eq!(parsed.handlers.len(), 1); + assert_eq!( + parsed.handlers[0].matcher, + ExternalHookMatcherSummary::Unavailable + ); +} + +#[test] +fn repeated_parse_failures_are_aggregated_by_issue_kind() { + let parsed = parse_hook_document( + br#"{"hooks":{"PreToolUse":[{"hooks":[{}, {}, {}]}]}}"#, + StaticHookDocumentFormat::Json, + RULES, + 8, + ); + + assert_eq!( + parsed + .issues + .iter() + .filter(|issue| **issue == StaticHookParseIssue::HandlerInvalid) + .count(), + 1 + ); +} diff --git a/src/crates/assembly/core/Cargo.toml b/src/crates/assembly/core/Cargo.toml index 52397a7b51..16d9fdf572 100644 --- a/src/crates/assembly/core/Cargo.toml +++ b/src/crates/assembly/core/Cargo.toml @@ -141,6 +141,8 @@ bitfun-runtime-services = { path = "../../execution/runtime-services" } # Reviewed product-full plugin composition root. bitfun-opencode-adapter = { path = "../../adapters/opencode-adapter", optional = true } +bitfun-claude-code-adapter = { path = "../../adapters/claude-code-adapter", optional = true } +bitfun-codex-adapter = { path = "../../adapters/codex-adapter", optional = true } bitfun-plugin-runtime-host = { path = "../../execution/plugin-runtime-host", optional = true } # Transport layer dependency @@ -177,6 +179,8 @@ product-full = [ "dep:globset", "dep:include_dir", "dep:bitfun-opencode-adapter", + "dep:bitfun-claude-code-adapter", + "dep:bitfun-codex-adapter", "dep:bitfun-external-sources", "dep:bitfun-plugin-runtime-host", "dep:indexmap", diff --git a/src/crates/assembly/core/src/external_hooks.rs b/src/crates/assembly/core/src/external_hooks.rs new file mode 100644 index 0000000000..dbbde8778c --- /dev/null +++ b/src/crates/assembly/core/src/external_hooks.rs @@ -0,0 +1,327 @@ +//! Product wiring for the runtime-free external Hook catalog. +//! +//! This service is deliberately separate from the command/tool/subagent/MCP +//! control plane. Inspecting Hooks must not load code, recover workers, mutate +//! integration policy, or refresh executable external capabilities. + +pub use bitfun_product_domains::external_hook_catalog::{ + ExternalHookCatalogEntry, ExternalHookCatalogSnapshotV1, ExternalHookHandlerKind, + ExternalHookMatcherSummary, ExternalHookNativeActivation, ExternalHookProjectionStatus, + ExternalHookSource, ExternalHookSourceKind, EXTERNAL_HOOK_CATALOG_SCHEMA_V1, +}; +pub use bitfun_product_domains::external_sources::{ExecutionDomainId, ExternalSourceContext}; + +use crate::external_sources::{host_execution_domain_id, normalize_workspace_root}; +use bitfun_claude_code_adapter::{ClaudeCodeHookProvider, ClaudeCodeHookProviderOptions}; +use bitfun_codex_adapter::{CodexHookProvider, CodexHookProviderOptions}; +use bitfun_external_sources::ExternalHookCatalogCoordinator; +use bitfun_opencode_adapter::{OpenCodeHookProvider, OpenCodeHookProviderOptions}; +use bitfun_product_domains::external_hook_catalog::ExternalHookSourceProvider; +use bitfun_product_domains::external_sources::{ + ExternalSourceOperationError, ExternalSourceOperationErrorCode, ExternalSourceOperationResult, +}; +#[cfg(feature = "service-integrations")] +use bitfun_services_integrations::git::GitService; +use std::collections::BTreeMap; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, OnceLock}; +use std::time::Duration; + +const MAX_CACHED_WORKSPACES: usize = 32; +#[cfg(not(test))] +const HOOK_PROVIDER_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(5); +#[cfg(test)] +const HOOK_PROVIDER_DISCOVERY_TIMEOUT: Duration = Duration::from_millis(100); + +pub(crate) struct WorkspaceExternalHookCatalogService { + coordinator: Arc, + refresh_gate: tokio::sync::Mutex<()>, +} + +impl WorkspaceExternalHookCatalogService { + pub(crate) fn new( + context: ExternalSourceContext, + providers: Vec>, + ) -> Result { + Ok(Self { + coordinator: Arc::new(ExternalHookCatalogCoordinator::new(context, providers)?), + refresh_gate: tokio::sync::Mutex::new(()), + }) + } + + pub(crate) fn snapshot(&self) -> ExternalHookCatalogSnapshotV1 { + self.coordinator.snapshot() + } + + pub(crate) async fn snapshot_or_refresh( + self: &Arc, + force_refresh: bool, + ) -> ExternalSourceOperationResult { + let snapshot = self.snapshot(); + if force_refresh || snapshot.discovery_pending { + self.refresh().await + } else { + Ok(snapshot) + } + } + + pub(crate) async fn refresh( + self: &Arc, + ) -> ExternalSourceOperationResult { + let _refresh_guard = self.refresh_gate.lock().await; + // The read-only Hook catalog never needs concurrent generations. A + // repeated Desktop/TUI refresh observes the shared pending snapshot + // instead of queuing work behind an uncancellable filesystem worker. + // This keeps the existing lane's 30-second terminal bound intact. + if self.coordinator.has_in_flight().await { + return Ok(self.coordinator.snapshot()); + } + let started = std::time::Instant::now(); + let batch = self + .coordinator + .discover(HOOK_PROVIDER_DISCOVERY_TIMEOUT) + .await; + let snapshot = self.coordinator.apply_discovery_results(batch.immediate); + for deferred in batch.deferred { + schedule_deferred_hook_discovery(Arc::clone(self), deferred); + } + log::info!( + "External Hook catalog refreshed sources={} entries={} diagnostics={} stale_providers={} failed_providers={} elapsed_ms={}", + snapshot.sources.len(), + snapshot.entries.len(), + snapshot.diagnostics.len(), + snapshot.stale_provider_ids.len(), + snapshot.failed_provider_ids.len(), + started.elapsed().as_millis(), + ); + Ok(snapshot) + } +} + +fn schedule_deferred_hook_discovery( + service: Arc, + deferred: bitfun_external_sources::DeferredDiscovery< + bitfun_external_sources::ExternalHookDiscoveryResult, + >, +) { + tokio::spawn(async move { + let mut deferred = deferred; + loop { + let Some((completed, observer)) = service.coordinator.complete_deferred(deferred).await + else { + return; + }; + { + // Serialize lane finalization, snapshot publication, and a new + // refresh exactly like the shared external-source service. + let _refresh_guard = service.refresh_gate.lock().await; + let Some(result) = service.coordinator.finalize_deferred(completed).await else { + return; + }; + service.coordinator.apply_discovery_result(result); + } + let Some(observer) = observer else { + return; + }; + let Some(next) = service.coordinator.resume_abandoned(observer).await else { + return; + }; + deferred = next; + } + }); +} + +struct CachedHookCatalogService { + service: Arc, + last_used: u64, +} + +fn service_cache( +) -> &'static tokio::sync::Mutex, CachedHookCatalogService>> { + static CACHE: OnceLock< + tokio::sync::Mutex, CachedHookCatalogService>>, + > = OnceLock::new(); + CACHE.get_or_init(|| tokio::sync::Mutex::new(BTreeMap::new())) +} + +fn next_access_tick() -> u64 { + static ACCESS_TICK: AtomicU64 = AtomicU64::new(1); + ACCESS_TICK.fetch_add(1, Ordering::Relaxed) +} + +async fn service_for( + workspace_root: Option<&std::path::Path>, +) -> ExternalSourceOperationResult> { + let workspace_root = normalize_workspace_root(workspace_root).map_err(|error| { + ExternalSourceOperationError::new( + ExternalSourceOperationErrorCode::InvalidRequest, + error, + false, + ) + })?; + { + let mut cache = service_cache().lock().await; + if let Some(cached) = cache.get_mut(&workspace_root) { + cached.last_used = next_access_tick(); + return Ok(Arc::clone(&cached.service)); + } + } + let project_topology = hook_project_topology(workspace_root.as_deref()).await; + let project_boundary = project_topology + .as_ref() + .map(|topology| topology.current_root.clone()); + let primary_checkout_root = project_topology + .as_ref() + .and_then(|topology| topology.primary_root.clone()); + let service = Arc::new( + WorkspaceExternalHookCatalogService::new( + ExternalSourceContext { + workspace_root: workspace_root.clone(), + execution_domain_id: host_execution_domain_id().map_err(|error| { + ExternalSourceOperationError::new( + ExternalSourceOperationErrorCode::Internal, + error, + false, + ) + })?, + }, + vec![ + Arc::new(OpenCodeHookProvider::new(OpenCodeHookProviderOptions { + project_root_override: project_boundary.clone(), + ..OpenCodeHookProviderOptions::default() + })), + Arc::new(ClaudeCodeHookProvider::new(ClaudeCodeHookProviderOptions { + project_root_override: project_boundary.clone(), + ..ClaudeCodeHookProviderOptions::default() + })), + Arc::new(CodexHookProvider::new(CodexHookProviderOptions { + project_root_override: project_boundary, + project_hooks_root_override: primary_checkout_root, + ..CodexHookProviderOptions::default() + })), + ], + ) + .map_err(|error| { + ExternalSourceOperationError::new( + ExternalSourceOperationErrorCode::Internal, + error, + false, + ) + })?, + ); + let mut cache = service_cache().lock().await; + if let Some(cached) = cache.get_mut(&workspace_root) { + // Service construction runs without the cache lock. Another caller + // may have inserted a newer access meanwhile, so allocate a fresh LRU + // tick instead of moving that shared entry backwards in time. + cached.last_used = next_access_tick(); + return Ok(Arc::clone(&cached.service)); + } + if cache.len() >= MAX_CACHED_WORKSPACES { + let oldest = cache + .iter() + .min_by_key(|(key, cached)| (cached.last_used, (*key).clone())) + .map(|(key, _)| key.clone()); + if let Some(oldest) = oldest { + cache.remove(&oldest); + } + } + cache.insert( + workspace_root, + CachedHookCatalogService { + service: Arc::clone(&service), + last_used: next_access_tick(), + }, + ); + Ok(service) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct HookProjectTopology { + pub(crate) current_root: PathBuf, + pub(crate) primary_root: Option, +} + +#[cfg(feature = "service-integrations")] +async fn hook_project_topology( + workspace_root: Option<&std::path::Path>, +) -> Option { + let workspace_root = workspace_root?; + let worktrees = GitService::list_worktrees(workspace_root).await.ok()?; + resolve_hook_project_topology( + workspace_root, + &worktrees + .iter() + .map(|worktree| (PathBuf::from(&worktree.path), worktree.is_main)) + .collect::>(), + ) +} + +#[cfg(not(feature = "service-integrations"))] +async fn hook_project_topology( + _workspace_root: Option<&std::path::Path>, +) -> Option { + None +} + +pub(crate) fn resolve_hook_project_topology( + workspace_root: &std::path::Path, + worktrees: &[(PathBuf, bool)], +) -> Option { + let main_root = worktrees.iter().find(|(_, is_main)| *is_main)?.0.clone(); + let (current_root, is_main) = worktrees + .iter() + .filter_map(|(path, is_main)| { + workspace_root + .strip_prefix(path) + .ok() + .map(|_| (path, *is_main)) + }) + .max_by_key(|(path, _)| path.components().count())?; + Some(HookProjectTopology { + current_root: current_root.clone(), + primary_root: (!is_main && current_root != &main_root).then_some(main_root), + }) +} + +/// Returns a local, static Hook catalog. Remote execution domains are rejected +/// before consulting the local filesystem so remote surfaces cannot +/// accidentally display Hooks from the controller machine. +pub async fn external_hook_catalog_snapshot( + context: ExternalSourceContext, + force_refresh: bool, +) -> ExternalSourceOperationResult { + let host_domain = host_execution_domain_id().map_err(|error| { + ExternalSourceOperationError::new(ExternalSourceOperationErrorCode::Internal, error, false) + })?; + if context.execution_domain_id != host_domain { + return Err(ExternalSourceOperationError::new( + ExternalSourceOperationErrorCode::Unsupported, + "Static Hook inspection is not available for a remote workspace in this version", + false, + )); + } + let service = service_for(context.workspace_root.as_deref()).await?; + service.snapshot_or_refresh(force_refresh).await +} + +/// Convenience entry point for local product surfaces. Keeping execution-domain +/// construction here prevents transport adapters from depending on the +/// internal local-domain identifier. +pub async fn local_external_hook_catalog_snapshot( + workspace_root: Option<&std::path::Path>, + force_refresh: bool, +) -> ExternalSourceOperationResult { + let execution_domain_id = host_execution_domain_id().map_err(|error| { + ExternalSourceOperationError::new(ExternalSourceOperationErrorCode::Internal, error, false) + })?; + external_hook_catalog_snapshot( + ExternalSourceContext { + workspace_root: workspace_root.map(std::path::Path::to_path_buf), + execution_domain_id, + }, + force_refresh, + ) + .await +} diff --git a/src/crates/assembly/core/src/external_hooks_tests.rs b/src/crates/assembly/core/src/external_hooks_tests.rs new file mode 100644 index 0000000000..67116bf846 --- /dev/null +++ b/src/crates/assembly/core/src/external_hooks_tests.rs @@ -0,0 +1,160 @@ +use crate::external_hooks::{ + external_hook_catalog_snapshot, resolve_hook_project_topology, + WorkspaceExternalHookCatalogService, +}; +use bitfun_product_domains::external_hook_catalog::{ + ExternalHookProviderIdentity, ExternalHookProviderSnapshot, ExternalHookSourceProvider, +}; +use bitfun_product_domains::external_sources::{ + ExecutionDomainId, ExternalSourceContext, ExternalSourceOperationErrorCode, + ExternalSourceProviderError, +}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; + +struct CountingProvider { + calls: AtomicUsize, + identity: ExternalHookProviderIdentity, +} + +impl CountingProvider { + fn new() -> Self { + Self { + calls: AtomicUsize::new(0), + identity: ExternalHookProviderIdentity::new("test.hooks", "test", "Test Hooks") + .unwrap(), + } + } +} + +impl ExternalHookSourceProvider for CountingProvider { + fn identity(&self) -> ExternalHookProviderIdentity { + self.identity.clone() + } + + fn discover( + &self, + _context: &ExternalSourceContext, + ) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + Ok(ExternalHookProviderSnapshot { + provider: self.identity.clone(), + sources: Vec::new(), + entries: Vec::new(), + diagnostics: Vec::new(), + }) + } +} + +struct SlowCountingProvider { + calls: AtomicUsize, + identity: ExternalHookProviderIdentity, +} + +impl SlowCountingProvider { + fn new() -> Self { + Self { + calls: AtomicUsize::new(0), + identity: ExternalHookProviderIdentity::new("slow.hooks", "slow", "Slow Hooks") + .unwrap(), + } + } +} + +impl ExternalHookSourceProvider for SlowCountingProvider { + fn identity(&self) -> ExternalHookProviderIdentity { + self.identity.clone() + } + + fn discover( + &self, + _context: &ExternalSourceContext, + ) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + std::thread::sleep(std::time::Duration::from_millis(250)); + Ok(ExternalHookProviderSnapshot { + provider: self.identity.clone(), + sources: Vec::new(), + entries: Vec::new(), + diagnostics: Vec::new(), + }) + } +} + +fn context(domain: &str) -> ExternalSourceContext { + ExternalSourceContext { + workspace_root: Some(PathBuf::from("C:/workspace")), + execution_domain_id: ExecutionDomainId::new(domain).unwrap(), + } +} + +#[tokio::test] +async fn workspace_service_refreshes_only_registered_hook_providers() { + let provider = Arc::new(CountingProvider::new()); + let service = Arc::new( + WorkspaceExternalHookCatalogService::new(context("local-user"), vec![provider.clone()]) + .unwrap(), + ); + + assert!(service.snapshot().discovery_pending); + assert!(!service.refresh().await.unwrap().discovery_pending); + assert_eq!(provider.calls.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn repeated_refresh_reuses_the_in_flight_hook_discovery() { + let provider = Arc::new(SlowCountingProvider::new()); + let service = Arc::new( + WorkspaceExternalHookCatalogService::new(context("local-user"), vec![provider.clone()]) + .unwrap(), + ); + + assert!(service.refresh().await.unwrap().discovery_pending); + assert!(service.refresh().await.unwrap().discovery_pending); + for _ in 0..200 { + if !service.snapshot().discovery_pending { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + + assert!(!service.snapshot().discovery_pending); + assert_eq!(provider.calls.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn initial_cache_only_request_starts_the_first_discovery() { + let provider = Arc::new(CountingProvider::new()); + let service = Arc::new( + WorkspaceExternalHookCatalogService::new(context("local-user"), vec![provider.clone()]) + .unwrap(), + ); + + let snapshot = service.snapshot_or_refresh(false).await.unwrap(); + + assert!(!snapshot.discovery_pending); + assert_eq!(provider.calls.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn remote_execution_domain_fails_closed_before_local_discovery() { + let error = external_hook_catalog_snapshot(context("peer-machine"), true) + .await + .unwrap_err(); + assert_eq!(error.code, ExternalSourceOperationErrorCode::Unsupported); + assert!(error.detail.contains("remote workspace")); +} + +#[test] +fn linked_worktree_topology_keeps_current_boundary_and_primary_hook_root() { + let main = PathBuf::from("C:/repo"); + let linked = PathBuf::from("C:/worktrees/feature"); + let workspace = linked.join("packages/app"); + + let topology = + resolve_hook_project_topology(&workspace, &[(main.clone(), true), (linked.clone(), false)]) + .unwrap(); + assert_eq!(topology.current_root, linked); + assert_eq!(topology.primary_root, Some(main)); +} diff --git a/src/crates/assembly/core/src/external_sources.rs b/src/crates/assembly/core/src/external_sources.rs index 95067388c6..0d49b1c033 100644 --- a/src/crates/assembly/core/src/external_sources.rs +++ b/src/crates/assembly/core/src/external_sources.rs @@ -416,7 +416,7 @@ fn config_update_refreshes_external_model_bindings(event: &ConfigUpdateEvent) -> matches!(event, ConfigUpdateEvent::ModelConfigurationUpdated) } -fn host_execution_domain_id() -> Result { +pub(crate) fn host_execution_domain_id() -> Result { ExecutionDomainId::new(LEGACY_LOCAL_EXECUTION_DOMAIN_ID).map_err(|error| error.to_string()) } @@ -2849,7 +2849,9 @@ fn workspace_service_gate() -> &'static tokio::sync::Mutex<()> { GATE.get_or_init(|| tokio::sync::Mutex::new(())) } -fn normalize_workspace_root(workspace_root: Option<&Path>) -> Result, String> { +pub(crate) fn normalize_workspace_root( + workspace_root: Option<&Path>, +) -> Result, String> { let Some(workspace_root) = workspace_root else { return Ok(None); }; diff --git a/src/crates/assembly/core/src/lib.rs b/src/crates/assembly/core/src/lib.rs index 94cc7e666a..67c603011d 100644 --- a/src/crates/assembly/core/src/lib.rs +++ b/src/crates/assembly/core/src/lib.rs @@ -8,6 +8,10 @@ #[cfg(feature = "product-full")] pub mod agentic; // Agent system, tool system, and product runtime orchestration #[cfg(feature = "product-full")] +pub mod external_hooks; +#[cfg(all(test, feature = "product-full"))] +mod external_hooks_tests; +#[cfg(feature = "product-full")] mod external_mcp; #[cfg(all(test, feature = "product-full"))] mod external_mcp_tests; diff --git a/src/crates/assembly/external-sources/src/hook.rs b/src/crates/assembly/external-sources/src/hook.rs new file mode 100644 index 0000000000..e0536450ff --- /dev/null +++ b/src/crates/assembly/external-sources/src/hook.rs @@ -0,0 +1,351 @@ +use crate::refresh::{CompletedDeferredDiscovery, DiscoveryRequest}; +use crate::{DeferredDiscovery, DiscoveryBatch, DiscoveryLane}; +use bitfun_product_domains::external_hook_catalog::{ + ExternalHookCatalogSnapshotV1, ExternalHookProviderIdentity, ExternalHookProviderSnapshot, + ExternalHookSourceProvider, EXTERNAL_HOOK_CATALOG_SCHEMA_V1, +}; +use bitfun_product_domains::external_sources::{ + ExternalSourceAssetKind, ExternalSourceContext, ExternalSourceDiagnostic, + ExternalSourceDiagnosticSeverity, ExternalSourceProviderError, ProviderId, +}; +use std::collections::BTreeSet; +use std::fmt; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::Duration; + +struct HookProviderGeneration { + provider: Arc, + identity: ExternalHookProviderIdentity, + initial_result_received: bool, + discovery_pending: bool, + last_success: Option, + last_error: Option, +} + +struct HookCatalogState { + context: ExternalSourceContext, + providers: Vec, + snapshot: ExternalHookCatalogSnapshotV1, +} + +/// One provider-neutral static discovery unit. It contains no runtime Host and +/// can only invoke the read-only source-provider contract. +struct ExternalHookDiscoveryRequest { + provider_id: ProviderId, + provider: Arc, + context: ExternalSourceContext, +} + +impl ExternalHookDiscoveryRequest { + fn execute(self) -> ExternalHookDiscoveryResult { + let candidate = self.provider.discover(&self.context); + ExternalHookDiscoveryResult { + provider_id: self.provider_id, + candidate, + } + } +} + +#[derive(Clone)] +pub struct ExternalHookDiscoveryResult { + provider_id: ProviderId, + candidate: Result, +} + +impl ExternalHookDiscoveryResult { + pub fn provider_id(&self) -> &ProviderId { + &self.provider_id + } + + fn failed(provider_id: ProviderId, error: ExternalSourceProviderError) -> Self { + Self { + provider_id, + candidate: Err(error), + } + } +} + +impl DiscoveryRequest for ExternalHookDiscoveryRequest { + type Result = ExternalHookDiscoveryResult; + + const DIAGNOSTIC_PREFIX: &'static str = "external_hook"; + const PROVIDER_LABEL: &'static str = "Hook"; + + fn provider_id(&self) -> ProviderId { + self.provider_id.clone() + } + + fn execute(self) -> Self::Result { + ExternalHookDiscoveryRequest::execute(self) + } + + fn failed(provider_id: ProviderId, error: ExternalSourceProviderError) -> Self::Result { + ExternalHookDiscoveryResult::failed(provider_id, error) + } +} + +/// Owns the static Hook catalog generations and reuses the process-wide typed +/// discovery scheduler. Repeated Desktop/TUI refreshes therefore coalesce per +/// provider instead of accumulating blocking filesystem workers. +pub struct ExternalHookCatalogCoordinator { + state: Mutex, + lane: DiscoveryLane, +} + +impl fmt::Debug for ExternalHookCatalogCoordinator { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let state = lock(&self.state); + formatter + .debug_struct("ExternalHookCatalogCoordinator") + .field("context", &state.context) + .field("providers", &state.providers.len()) + .field("discovery_pending", &state.snapshot.discovery_pending) + .finish() + } +} + +impl ExternalHookCatalogCoordinator { + pub fn new( + context: ExternalSourceContext, + providers: Vec>, + ) -> Result { + let mut provider_ids = BTreeSet::new(); + let mut generations = Vec::with_capacity(providers.len()); + for provider in providers { + let identity = provider.identity(); + identity + .validate() + .map_err(|error| format!("invalid external Hook provider: {error}"))?; + if !provider_ids.insert(identity.provider_id.clone()) { + return Err(format!( + "duplicate external Hook provider id: {}", + identity.provider_id + )); + } + generations.push(HookProviderGeneration { + provider, + identity, + initial_result_received: false, + discovery_pending: false, + last_success: None, + last_error: None, + }); + } + let mut snapshot = ExternalHookCatalogSnapshotV1::default(); + snapshot.discovery_pending = !generations.is_empty(); + snapshot.providers = generations + .iter() + .map(|provider| provider.identity.clone()) + .collect(); + Ok(Self { + state: Mutex::new(HookCatalogState { + context, + providers: generations, + snapshot, + }), + lane: DiscoveryLane::new(), + }) + } + + pub fn snapshot(&self) -> ExternalHookCatalogSnapshotV1 { + lock(&self.state).snapshot.clone() + } + + pub async fn discover(&self, timeout: Duration) -> DiscoveryBatch { + let requests = { + let mut state = lock(&self.state); + for provider in &mut state.providers { + provider.discovery_pending = true; + } + rebuild_snapshot(&mut state); + state + .providers + .iter() + .map(|provider| ExternalHookDiscoveryRequest { + provider_id: provider.identity.provider_id.clone(), + provider: Arc::clone(&provider.provider), + context: state.context.clone(), + }) + .collect() + }; + self.lane.discover(requests, timeout).await + } + + pub fn apply_discovery_results( + &self, + results: Vec, + ) -> ExternalHookCatalogSnapshotV1 { + let mut state = lock(&self.state); + for result in results { + if let Some(provider) = state + .providers + .iter_mut() + .find(|provider| provider.identity.provider_id == result.provider_id) + { + apply_provider_candidate(provider, result.candidate); + } + } + rebuild_snapshot(&mut state) + } + + pub fn apply_discovery_result( + &self, + result: ExternalHookDiscoveryResult, + ) -> ExternalHookCatalogSnapshotV1 { + self.apply_discovery_results(vec![result]) + } + + pub async fn complete_deferred( + &self, + deferred: DeferredDiscovery, + ) -> Option<( + CompletedDeferredDiscovery, + Option>, + )> { + self.lane.complete_deferred(deferred).await + } + + pub async fn has_in_flight(&self) -> bool { + self.lane.has_in_flight().await + } + + pub async fn finalize_deferred( + &self, + completed: CompletedDeferredDiscovery, + ) -> Option { + self.lane.finalize_deferred(completed).await + } + + pub async fn resume_abandoned( + &self, + deferred: DeferredDiscovery, + ) -> Option> { + self.lane.resume_abandoned(deferred).await + } +} + +fn rebuild_snapshot(state: &mut HookCatalogState) -> ExternalHookCatalogSnapshotV1 { + let mut sources = Vec::new(); + let mut entries = Vec::new(); + let mut stale_provider_ids = Vec::new(); + let mut failed_provider_ids = Vec::new(); + let mut diagnostics = Vec::new(); + let mut entry_keys = BTreeSet::new(); + for provider in &state.providers { + if let Some(snapshot) = &provider.last_success { + sources.extend(snapshot.sources.clone()); + diagnostics.extend(snapshot.diagnostics.clone()); + for entry in &snapshot.entries { + if entry_keys.insert(entry.stable_key.clone()) { + entries.push(entry.clone()); + } else { + diagnostics.push(hook_diagnostic( + ExternalSourceDiagnosticSeverity::Error, + "external_hook.duplicate_entry_key", + "Duplicate external Hook entry identity was omitted", + )); + } + } + } + if let Some(error) = &provider.last_error { + if provider.last_success.is_some() { + stale_provider_ids.push(provider.identity.provider_id.clone()); + } else { + failed_provider_ids.push(provider.identity.provider_id.clone()); + } + diagnostics.push(hook_diagnostic( + if error.transient { + ExternalSourceDiagnosticSeverity::Warning + } else { + ExternalSourceDiagnosticSeverity::Error + }, + &error.code, + &error.message, + )); + } + } + diagnostics + .sort_by(|left, right| (&left.code, &left.message).cmp(&(&right.code, &right.message))); + state.snapshot = ExternalHookCatalogSnapshotV1 { + schema_version: EXTERNAL_HOOK_CATALOG_SCHEMA_V1, + discovery_pending: state + .providers + .iter() + .any(|provider| provider.discovery_pending || !provider.initial_result_received), + providers: state + .providers + .iter() + .map(|provider| provider.identity.clone()) + .collect(), + sources, + entries, + stale_provider_ids, + failed_provider_ids, + diagnostics, + }; + state.snapshot.clone() +} + +fn apply_provider_candidate( + generation: &mut HookProviderGeneration, + candidate: Result, +) { + if let Err(error) = &candidate { + if matches!( + error.code.as_str(), + "external_hook.discovery_timeout" | "external_hook.discovery_in_progress" + ) { + generation.discovery_pending = true; + return; + } + } + generation.discovery_pending = false; + generation.initial_result_received = true; + match candidate { + Ok(snapshot) => match snapshot.validate() { + Ok(()) if snapshot.provider == generation.identity => { + generation.last_success = Some(snapshot); + generation.last_error = None; + } + Ok(()) => { + generation.last_error = Some(ExternalSourceProviderError::new( + "external_hook.provider_identity_mismatch", + "Hook provider returned a mismatched identity", + false, + )); + } + Err(error) => { + generation.last_error = Some(ExternalSourceProviderError::new( + "external_hook.snapshot_invalid", + error.to_string(), + false, + )); + } + }, + Err(error) => generation.last_error = Some(error), + } +} + +fn hook_diagnostic( + severity: ExternalSourceDiagnosticSeverity, + code: &str, + message: &str, +) -> ExternalSourceDiagnostic { + ExternalSourceDiagnostic { + severity, + asset_kind: ExternalSourceAssetKind::Hook, + code: code.to_string(), + message: message.to_string(), + source: None, + } +} + +fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { + match mutex.lock() { + Ok(guard) => guard, + Err(poisoned) => { + log::error!("External Hook catalog coordinator mutex poisoned"); + poisoned.into_inner() + } + } +} diff --git a/src/crates/assembly/external-sources/src/lib.rs b/src/crates/assembly/external-sources/src/lib.rs index 9587894880..288dc9531a 100644 --- a/src/crates/assembly/external-sources/src/lib.rs +++ b/src/crates/assembly/external-sources/src/lib.rs @@ -5,12 +5,14 @@ //! product composition root. mod control_plane; +mod hook; mod mcp; mod refresh; mod subagent; mod tool; pub use control_plane::ExternalSourceControlPlane; +pub use hook::{ExternalHookCatalogCoordinator, ExternalHookDiscoveryResult}; pub use refresh::{DeferredDiscovery, DiscoveryBatch}; use refresh::{DiscoveryLane, DiscoveryRequest}; diff --git a/src/crates/assembly/external-sources/src/refresh.rs b/src/crates/assembly/external-sources/src/refresh.rs index 231e01c303..bd77cf83af 100644 --- a/src/crates/assembly/external-sources/src/refresh.rs +++ b/src/crates/assembly/external-sources/src/refresh.rs @@ -435,6 +435,10 @@ impl DiscoveryLane { Some(completed.result) } + pub(crate) async fn has_in_flight(&self) -> bool { + !self.tasks.lock().await.is_empty() + } + #[cfg(test)] async fn cancel(&self, provider_id: &str) -> bool { let mut tasks = self.tasks.lock().await; diff --git a/src/crates/assembly/external-sources/tests/hook_coordinator.rs b/src/crates/assembly/external-sources/tests/hook_coordinator.rs new file mode 100644 index 0000000000..fe4e9c9086 --- /dev/null +++ b/src/crates/assembly/external-sources/tests/hook_coordinator.rs @@ -0,0 +1,217 @@ +use bitfun_external_sources::ExternalHookCatalogCoordinator; +use bitfun_product_domains::external_hook_catalog::{ + ExternalHookCatalogEntry, ExternalHookHandlerKind, ExternalHookMapping, + ExternalHookMatcherSummary, ExternalHookNativeActivation, ExternalHookProjectionStatus, + ExternalHookProviderIdentity, ExternalHookProviderSnapshot, ExternalHookSource, + ExternalHookSourceKind, ExternalHookSourceProvider, +}; +use bitfun_product_domains::external_hook_contributions::ExternalHookPoint; +use bitfun_product_domains::external_sources::{ + EcosystemId, ExecutionDomainId, ExternalSourceContext, ExternalSourceHealth, + ExternalSourceProviderError, ExternalSourceScope, SourceKey, +}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +struct FakeProvider { + identity: ExternalHookProviderIdentity, + snapshot: Mutex>, + delay: Mutex, +} + +impl FakeProvider { + fn new(provider_id: &str, ecosystem_id: &str, event: &str) -> Self { + let identity = + ExternalHookProviderIdentity::new(provider_id, ecosystem_id, provider_id).unwrap(); + Self { + snapshot: Mutex::new(Ok(snapshot(identity.clone(), event, "v1"))), + delay: Mutex::new(std::time::Duration::ZERO), + identity, + } + } + + fn set_snapshot( + &self, + snapshot: Result, + ) { + *self.snapshot.lock().unwrap() = snapshot; + } + + fn set_delay(&self, delay: std::time::Duration) { + *self.delay.lock().unwrap() = delay; + } +} + +impl ExternalHookSourceProvider for FakeProvider { + fn identity(&self) -> ExternalHookProviderIdentity { + self.identity.clone() + } + + fn discover( + &self, + _context: &ExternalSourceContext, + ) -> Result { + std::thread::sleep(*self.delay.lock().unwrap()); + self.snapshot.lock().unwrap().clone() + } +} + +fn snapshot( + identity: ExternalHookProviderIdentity, + event: &str, + version: &str, +) -> ExternalHookProviderSnapshot { + let source_key = SourceKey::new(identity.provider_id.as_str(), "project").unwrap(); + ExternalHookProviderSnapshot { + provider: identity.clone(), + sources: vec![ExternalHookSource { + key: source_key.clone(), + ecosystem_id: identity.ecosystem_id, + display_name: "Project hooks".to_string(), + source_kind: ExternalHookSourceKind::Settings, + scope: ExternalSourceScope::Project, + location_hint: ".example/settings.json".to_string(), + health: ExternalSourceHealth::Available, + content_version: version.to_string(), + diagnostics: Vec::new(), + }], + entries: vec![ExternalHookCatalogEntry { + stable_key: format!("{}:{event}", identity.provider_id), + source: source_key, + native_event: event.to_string(), + matcher: ExternalHookMatcherSummary::Any, + handler_kind: ExternalHookHandlerKind::Command, + projection_status: ExternalHookProjectionStatus::Mapped, + native_activation: ExternalHookNativeActivation::Unknown, + mapping: Some(ExternalHookMapping { + hook_point: ExternalHookPoint::ToolBefore, + }), + content_version: version.to_string(), + }], + diagnostics: Vec::new(), + } +} + +fn context() -> ExternalSourceContext { + ExternalSourceContext { + workspace_root: Some(PathBuf::from("/workspace")), + execution_domain_id: ExecutionDomainId::new("local-user").unwrap(), + } +} + +async fn refresh( + coordinator: &ExternalHookCatalogCoordinator, +) -> bitfun_product_domains::external_hook_catalog::ExternalHookCatalogSnapshotV1 { + let batch = coordinator + .discover(std::time::Duration::from_secs(1)) + .await; + assert!(batch.deferred.is_empty()); + coordinator.apply_discovery_results(batch.immediate) +} + +#[tokio::test] +async fn catalog_is_pending_then_preserves_provider_registration_order() { + let zeta = Arc::new(FakeProvider::new("zeta.hooks", "zeta", "PreToolUse")); + let alpha = Arc::new(FakeProvider::new("alpha.hooks", "alpha", "PreToolUse")); + let coordinator = ExternalHookCatalogCoordinator::new(context(), vec![zeta, alpha]).unwrap(); + + assert!(coordinator.snapshot().discovery_pending); + let snapshot = refresh(&coordinator).await; + + assert!(!snapshot.discovery_pending); + assert_eq!(snapshot.entries.len(), 2); + assert_eq!( + snapshot.sources[0].ecosystem_id, + EcosystemId::new("zeta").unwrap() + ); + assert_eq!( + snapshot.entries[0].source.provider_id.as_str(), + "zeta.hooks" + ); + assert_eq!(snapshot.providers[0].provider_id.as_str(), "zeta.hooks"); +} + +#[tokio::test] +async fn failed_refresh_retains_last_valid_static_snapshot_and_marks_it_stale() { + let provider = Arc::new(FakeProvider::new( + "claude.hooks", + "claude-code", + "PreToolUse", + )); + let coordinator = + ExternalHookCatalogCoordinator::new(context(), vec![provider.clone()]).unwrap(); + assert_eq!(refresh(&coordinator).await.entries.len(), 1); + + provider.set_snapshot(Err(ExternalSourceProviderError::new( + "claude.hook.read_failed", + "read failed", + true, + ))); + let stale = refresh(&coordinator).await; + + assert_eq!(stale.entries.len(), 1); + assert_eq!( + stale.stale_provider_ids, + vec![provider.identity.provider_id.clone()] + ); + assert!(stale + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "claude.hook.read_failed")); +} + +#[tokio::test] +async fn initial_provider_failure_is_distinct_from_a_valid_empty_catalog() { + let provider = Arc::new(FakeProvider::new("failed.hooks", "failed", "PreToolUse")); + provider.set_snapshot(Err(ExternalSourceProviderError::new( + "failed.hook.read_failed", + "read failed", + true, + ))); + let coordinator = + ExternalHookCatalogCoordinator::new(context(), vec![provider.clone()]).unwrap(); + + let failed = refresh(&coordinator).await; + + assert_eq!( + failed.failed_provider_ids, + vec![provider.identity.provider_id.clone()] + ); + assert!(failed.stale_provider_ids.is_empty()); + assert!(failed.sources.is_empty()); +} + +#[tokio::test] +async fn timed_out_discovery_stays_pending_until_the_deferred_result_is_applied() { + let provider = Arc::new(FakeProvider::new("slow.hooks", "slow", "PreToolUse")); + provider.set_delay(std::time::Duration::from_millis(30)); + let coordinator = + ExternalHookCatalogCoordinator::new(context(), vec![provider.clone()]).unwrap(); + + let mut batch = coordinator + .discover(std::time::Duration::from_millis(1)) + .await; + assert_eq!(batch.deferred.len(), 1); + let pending = coordinator.apply_discovery_results(batch.immediate); + assert!(pending.discovery_pending); + assert!(pending.entries.is_empty()); + assert!(!pending + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "external_hook.discovery_timeout")); + + let deferred = batch.deferred.pop().unwrap(); + let (completed, observer) = coordinator.complete_deferred(deferred).await.unwrap(); + assert!(observer.is_none()); + let result = coordinator.finalize_deferred(completed).await.unwrap(); + let complete = coordinator.apply_discovery_result(result); + assert!(!complete.discovery_pending); + assert_eq!(complete.entries.len(), 1); +} + +#[test] +fn duplicate_provider_ids_are_rejected_before_discovery() { + let first = Arc::new(FakeProvider::new("same.hooks", "first", "PreToolUse")); + let second = Arc::new(FakeProvider::new("same.hooks", "second", "PostToolUse")); + assert!(ExternalHookCatalogCoordinator::new(context(), vec![first, second]).is_err()); +} diff --git a/src/crates/contracts/product-domains/Cargo.toml b/src/crates/contracts/product-domains/Cargo.toml index 08a90a3500..8a83f02f32 100644 --- a/src/crates/contracts/product-domains/Cargo.toml +++ b/src/crates/contracts/product-domains/Cargo.toml @@ -24,6 +24,11 @@ name = "external_hook_contribution_contracts" path = "tests/external_hook_contribution_contracts.rs" required-features = ["external-sources"] +[[test]] +name = "external_hook_catalog_contracts" +path = "tests/external_hook_catalog_contracts.rs" +required-features = ["external-sources"] + [dependencies] serde = { workspace = true } serde_json = { workspace = true } diff --git a/src/crates/contracts/product-domains/src/external_hook_catalog.rs b/src/crates/contracts/product-domains/src/external_hook_catalog.rs new file mode 100644 index 0000000000..70fbd208d4 --- /dev/null +++ b/src/crates/contracts/product-domains/src/external_hook_catalog.rs @@ -0,0 +1,325 @@ +//! Stable, runtime-free contracts for inspecting external AI application Hooks. +//! +//! Catalog entries intentionally contain summaries only. Handler source, +//! command arguments, request bodies, environment variables, and credentials +//! are not part of this contract and must remain inside the source adapter. + +use crate::external_hook_contributions::ExternalHookPoint; +use crate::external_sources::{ + validate_id, EcosystemId, ExternalSourceAssetKind, ExternalSourceContext, + ExternalSourceContractError, ExternalSourceDiagnostic, ExternalSourceHealth, + ExternalSourceProviderError, ExternalSourceScope, ProviderId, SourceKey, +}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; + +pub const EXTERNAL_HOOK_CATALOG_SCHEMA_V1: u32 = 1; +const MAX_SUMMARY_LENGTH: usize = 512; +const MAX_PROVIDER_SOURCES: usize = 2048; +const MAX_PROVIDER_ENTRIES: usize = 2048; +const MAX_PROVIDER_DIAGNOSTICS: usize = 4096; +const MAX_SOURCE_DIAGNOSTICS: usize = 256; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum ExternalHookSourceKind { + Settings, + PluginFile, + PackageDeclaration, + HooksFile, + InlineConfiguration, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum ExternalHookHandlerKind { + Function, + Command, + Http, + McpTool, + Prompt, + Agent, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum ExternalHookProjectionStatus { + Mapped, + NativeOnly, + Opaque, +} + +/// Best-effort activation state reported by the native product's static +/// configuration. `Unknown` is intentional: static inspection must not claim +/// that a handler is enabled, trusted, or executable when that requires the +/// native runtime. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum ExternalHookNativeActivation { + Disabled, + Unsupported, + Unknown, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +#[non_exhaustive] +pub enum ExternalHookMatcherSummary { + Any, + Pattern { display: String }, + Dynamic, + Unavailable, +} + +impl ExternalHookMatcherSummary { + fn validate(&self) -> Result<(), ExternalSourceContractError> { + if let Self::Pattern { display } = self { + validate_summary(display, "Hook matcher summary")?; + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalHookMapping { + pub hook_point: ExternalHookPoint, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalHookProviderIdentity { + pub provider_id: ProviderId, + pub ecosystem_id: EcosystemId, + pub display_name: String, +} + +impl ExternalHookProviderIdentity { + pub fn new( + provider_id: impl Into, + ecosystem_id: impl Into, + display_name: impl Into, + ) -> Result { + let identity = Self { + provider_id: ProviderId::new(provider_id)?, + ecosystem_id: EcosystemId::new(ecosystem_id)?, + display_name: display_name.into(), + }; + identity.validate()?; + Ok(identity) + } + + pub fn validate(&self) -> Result<(), ExternalSourceContractError> { + validate_summary(&self.display_name, "Hook provider display name") + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalHookSource { + pub key: SourceKey, + pub ecosystem_id: EcosystemId, + pub display_name: String, + pub source_kind: ExternalHookSourceKind, + pub scope: ExternalSourceScope, + /// A user-recognizable, secret-free location. It may be workspace-relative. + pub location_hint: String, + pub health: ExternalSourceHealth, + pub content_version: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub diagnostics: Vec, +} + +impl ExternalHookSource { + pub fn validate(&self) -> Result<(), ExternalSourceContractError> { + validate_summary(&self.display_name, "Hook source display name")?; + validate_summary(&self.location_hint, "Hook source location")?; + validate_id(&self.content_version, "Hook source content version")?; + if self.diagnostics.len() > MAX_SOURCE_DIAGNOSTICS { + return Err(ExternalSourceContractError::InvalidIdentifier( + "Hook source diagnostic count", + )); + } + for diagnostic in &self.diagnostics { + validate_hook_diagnostic(diagnostic)?; + if diagnostic + .source + .as_ref() + .is_some_and(|source| source != &self.key) + { + return Err(ExternalSourceContractError::InvalidIdentifier( + "Hook source diagnostic identity", + )); + } + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalHookCatalogEntry { + pub stable_key: String, + pub source: SourceKey, + pub native_event: String, + pub matcher: ExternalHookMatcherSummary, + pub handler_kind: ExternalHookHandlerKind, + pub projection_status: ExternalHookProjectionStatus, + pub native_activation: ExternalHookNativeActivation, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mapping: Option, + pub content_version: String, +} + +impl ExternalHookCatalogEntry { + pub fn validate(&self) -> Result<(), ExternalSourceContractError> { + validate_id(&self.stable_key, "Hook catalog entry")?; + validate_id(&self.native_event, "native Hook event")?; + validate_id(&self.content_version, "Hook entry content version")?; + self.matcher.validate()?; + match (self.projection_status, &self.mapping) { + (ExternalHookProjectionStatus::Mapped, Some(_)) + | (ExternalHookProjectionStatus::NativeOnly, None) + | (ExternalHookProjectionStatus::Opaque, None) => Ok(()), + _ => Err(ExternalSourceContractError::InvalidIdentifier( + "Hook projection mapping", + )), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalHookProviderSnapshot { + pub provider: ExternalHookProviderIdentity, + /// Deterministic adapter order. Product surfaces preserve this order. + pub sources: Vec, + /// Deterministic adapter order across the provider's sources. + pub entries: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub diagnostics: Vec, +} + +impl ExternalHookProviderSnapshot { + pub fn validate(&self) -> Result<(), ExternalSourceContractError> { + self.provider.validate()?; + if self.sources.len() > MAX_PROVIDER_SOURCES + || self.entries.len() > MAX_PROVIDER_ENTRIES + || self.diagnostics.len() > MAX_PROVIDER_DIAGNOSTICS + { + return Err(ExternalSourceContractError::InvalidIdentifier( + "Hook provider snapshot size", + )); + } + let mut source_keys = BTreeSet::new(); + for source in &self.sources { + source.validate()?; + if source.key.provider_id != self.provider.provider_id + || source.ecosystem_id != self.provider.ecosystem_id + || !source_keys.insert(source.key.clone()) + { + return Err(ExternalSourceContractError::InvalidIdentifier( + "provider-qualified Hook source", + )); + } + } + + let mut entry_keys = BTreeSet::new(); + for entry in &self.entries { + entry.validate()?; + if entry.source.provider_id != self.provider.provider_id + || !source_keys.contains(&entry.source) + || !entry_keys.insert(entry.stable_key.clone()) + { + return Err(ExternalSourceContractError::InvalidIdentifier( + "provider-qualified Hook entry", + )); + } + } + for diagnostic in &self.diagnostics { + validate_hook_diagnostic(diagnostic)?; + if diagnostic + .source + .as_ref() + .is_some_and(|source| !source_keys.contains(source)) + { + return Err(ExternalSourceContractError::InvalidIdentifier( + "Hook provider diagnostic identity", + )); + } + } + Ok(()) + } +} + +/// Capability-specific, runtime-free source provider implemented by an +/// ecosystem adapter. Discovery may read configuration files but must never +/// import, install, initialize, or invoke Hook handlers. +pub trait ExternalHookSourceProvider: Send + Sync { + fn identity(&self) -> ExternalHookProviderIdentity; + + fn discover( + &self, + context: &ExternalSourceContext, + ) -> Result; +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalHookCatalogSnapshotV1 { + pub schema_version: u32, + pub discovery_pending: bool, + /// Provider registration order. Product surfaces use these shared display + /// facts instead of branching on ecosystem ids. + pub providers: Vec, + /// Provider order followed by deterministic adapter source order. + pub sources: Vec, + /// Provider order followed by deterministic adapter Hook order. + pub entries: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub stale_provider_ids: Vec, + /// Providers whose latest discovery failed before any valid snapshot was + /// available. Error details remain in the shared diagnostic collection. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub failed_provider_ids: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub diagnostics: Vec, +} + +impl Default for ExternalHookCatalogSnapshotV1 { + fn default() -> Self { + Self { + schema_version: EXTERNAL_HOOK_CATALOG_SCHEMA_V1, + discovery_pending: true, + providers: Vec::new(), + sources: Vec::new(), + entries: Vec::new(), + stale_provider_ids: Vec::new(), + failed_provider_ids: Vec::new(), + diagnostics: Vec::new(), + } + } +} + +fn validate_summary(value: &str, label: &'static str) -> Result<(), ExternalSourceContractError> { + if value.is_empty() || value.len() > MAX_SUMMARY_LENGTH || value.chars().any(char::is_control) { + return Err(ExternalSourceContractError::InvalidText(label)); + } + Ok(()) +} + +fn validate_hook_diagnostic( + diagnostic: &ExternalSourceDiagnostic, +) -> Result<(), ExternalSourceContractError> { + if diagnostic.asset_kind != ExternalSourceAssetKind::Hook { + return Err(ExternalSourceContractError::InvalidIdentifier( + "Hook diagnostic asset kind", + )); + } + validate_id(&diagnostic.code, "Hook diagnostic code")?; + validate_summary(&diagnostic.message, "Hook diagnostic message") +} diff --git a/src/crates/contracts/product-domains/src/external_hook_contributions.rs b/src/crates/contracts/product-domains/src/external_hook_contributions.rs index 6985fd57e1..39bf7ccd2c 100644 --- a/src/crates/contracts/product-domains/src/external_hook_contributions.rs +++ b/src/crates/contracts/product-domains/src/external_hook_contributions.rs @@ -4,6 +4,7 @@ //! execution, runtime support, or a stable wire protocol. use crate::external_sources::{validate_id, ExternalSourceContractError}; +use serde::{Deserialize, Serialize}; use std::collections::BTreeSet; use std::fmt; @@ -30,7 +31,8 @@ impl fmt::Display for ExternalHookContributionId { } /// Hook points with a current static OpenCode mapping. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] pub enum ExternalHookPoint { ToolBefore, ToolAfter, diff --git a/src/crates/contracts/product-domains/src/external_sources.rs b/src/crates/contracts/product-domains/src/external_sources.rs index 9435d2fd94..712c489c51 100644 --- a/src/crates/contracts/product-domains/src/external_sources.rs +++ b/src/crates/contracts/product-domains/src/external_sources.rs @@ -1165,6 +1165,7 @@ pub enum ExternalSourceAssetKind { Tool, Subagent, Mcp, + Hook, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/src/crates/contracts/product-domains/src/lib.rs b/src/crates/contracts/product-domains/src/lib.rs index c187920f6c..b63296d564 100644 --- a/src/crates/contracts/product-domains/src/lib.rs +++ b/src/crates/contracts/product-domains/src/lib.rs @@ -12,6 +12,9 @@ pub mod external_integration_policy; #[cfg(feature = "external-sources")] pub mod external_hook_contributions; +#[cfg(feature = "external-sources")] +pub mod external_hook_catalog; + #[cfg(feature = "external-sources")] pub mod external_source_control; diff --git a/src/crates/contracts/product-domains/tests/external_hook_catalog_contracts.rs b/src/crates/contracts/product-domains/tests/external_hook_catalog_contracts.rs new file mode 100644 index 0000000000..1cb76ac04c --- /dev/null +++ b/src/crates/contracts/product-domains/tests/external_hook_catalog_contracts.rs @@ -0,0 +1,156 @@ +use bitfun_product_domains::external_hook_catalog::{ + ExternalHookCatalogEntry, ExternalHookCatalogSnapshotV1, ExternalHookHandlerKind, + ExternalHookMapping, ExternalHookMatcherSummary, ExternalHookNativeActivation, + ExternalHookProjectionStatus, ExternalHookProviderIdentity, ExternalHookProviderSnapshot, + ExternalHookSource, ExternalHookSourceKind, +}; +use bitfun_product_domains::external_hook_contributions::ExternalHookPoint; +use bitfun_product_domains::external_sources::{ + EcosystemId, ExternalSourceAssetKind, ExternalSourceDiagnostic, ExternalSourceHealth, + ExternalSourceScope, ProviderId, SourceKey, +}; + +fn provider() -> ExternalHookProviderIdentity { + ExternalHookProviderIdentity::new("claude-code.hooks", "claude-code", "Claude Code Hooks") + .unwrap() +} + +fn source() -> ExternalHookSource { + ExternalHookSource { + key: SourceKey::new("claude-code.hooks", "project-settings").unwrap(), + ecosystem_id: EcosystemId::new("claude-code").unwrap(), + display_name: "Claude Code project settings".to_string(), + source_kind: ExternalHookSourceKind::Settings, + scope: ExternalSourceScope::Project, + location_hint: ".claude/settings.json".to_string(), + health: ExternalSourceHealth::Available, + content_version: "sha256:0123456789abcdef".to_string(), + diagnostics: Vec::new(), + } +} + +fn entry(local_id: &str) -> ExternalHookCatalogEntry { + ExternalHookCatalogEntry { + stable_key: format!("claude-code.hooks:project-settings:{local_id}"), + source: SourceKey::new("claude-code.hooks", "project-settings").unwrap(), + native_event: "PreToolUse".to_string(), + matcher: ExternalHookMatcherSummary::Pattern { + display: "Bash|Edit".to_string(), + }, + handler_kind: ExternalHookHandlerKind::Command, + projection_status: ExternalHookProjectionStatus::Mapped, + native_activation: ExternalHookNativeActivation::Unknown, + mapping: Some(ExternalHookMapping { + hook_point: ExternalHookPoint::ToolBefore, + }), + content_version: "sha256:fedcba9876543210".to_string(), + } +} + +#[test] +fn hook_diagnostics_have_a_first_class_wire_kind() { + assert_eq!( + serde_json::to_value(ExternalSourceAssetKind::Hook).unwrap(), + "hook" + ); +} + +#[test] +fn catalog_wire_shape_is_redacted_and_uses_stable_names() { + let value = serde_json::to_value(entry("pre-tool-0")).unwrap(); + + assert_eq!(value["nativeEvent"], "PreToolUse"); + assert_eq!(value["handlerKind"], "command"); + assert_eq!(value["projectionStatus"], "mapped"); + assert_eq!(value["nativeActivation"], "unknown"); + assert_eq!(value["mapping"]["hookPoint"], "tool_before"); + assert!(value.get("command").is_none()); + assert!(value.get("script").is_none()); + assert!(value.get("payload").is_none()); + assert!(value.get("environment").is_none()); +} + +#[test] +fn only_mapped_entries_may_carry_a_reviewed_bitfun_hook_point() { + let mut invalid = entry("native-only"); + invalid.projection_status = ExternalHookProjectionStatus::NativeOnly; + assert!(invalid.validate().is_err()); + + invalid.mapping = None; + assert!(invalid.validate().is_ok()); +} + +#[test] +fn provider_snapshot_rejects_cross_provider_and_duplicate_entries() { + let valid = entry("pre-tool-0"); + let snapshot = ExternalHookProviderSnapshot { + provider: provider(), + sources: vec![source()], + entries: vec![valid.clone()], + diagnostics: Vec::new(), + }; + assert!(snapshot.validate().is_ok()); + + let duplicate = ExternalHookProviderSnapshot { + entries: vec![valid.clone(), valid], + ..snapshot.clone() + }; + assert!(duplicate.validate().is_err()); + + let foreign = ExternalHookProviderSnapshot { + sources: vec![ExternalHookSource { + key: SourceKey::new("other-provider", "project-settings").unwrap(), + ..source() + }], + ..snapshot + }; + assert!(foreign.validate().is_err()); +} + +#[test] +fn provider_snapshot_rejects_non_hook_or_foreign_diagnostics() { + let base = ExternalHookProviderSnapshot { + provider: provider(), + sources: vec![source()], + entries: vec![entry("pre-tool-0")], + diagnostics: Vec::new(), + }; + let non_hook = ExternalHookProviderSnapshot { + diagnostics: vec![ExternalSourceDiagnostic::warning( + "claude.hook.partial", + "Hook configuration is partially available", + None, + )], + ..base.clone() + }; + assert!(non_hook.validate().is_err()); + + let foreign = ExternalHookProviderSnapshot { + diagnostics: vec![ExternalSourceDiagnostic::warning( + "claude.hook.partial", + "Hook configuration is partially available", + Some(SourceKey::new("other-provider", "settings").unwrap()), + ) + .with_asset_kind(ExternalSourceAssetKind::Hook)], + ..base + }; + assert!(foreign.validate().is_err()); +} + +#[test] +fn provider_identity_keeps_ecosystems_open_without_an_ecosystem_enum() { + let identity = ExternalHookProviderIdentity { + provider_id: ProviderId::new("future.hooks").unwrap(), + ecosystem_id: EcosystemId::new("future-product/v3").unwrap(), + display_name: "Future Hooks".to_string(), + }; + assert!(identity.validate().is_ok()); +} + +#[test] +fn empty_catalog_is_pending_until_the_first_discovery_finishes() { + let snapshot = ExternalHookCatalogSnapshotV1::default(); + assert!(snapshot.discovery_pending); + assert_eq!(snapshot.schema_version, 1); + assert!(snapshot.providers.is_empty()); +} diff --git a/src/web-ui/src/infrastructure/api/service-api/ExternalHooksAPI.test.ts b/src/web-ui/src/infrastructure/api/service-api/ExternalHooksAPI.test.ts new file mode 100644 index 0000000000..c517a12c59 --- /dev/null +++ b/src/web-ui/src/infrastructure/api/service-api/ExternalHooksAPI.test.ts @@ -0,0 +1,182 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const invokeMock = vi.hoisted(() => vi.fn()); + +vi.mock('./ApiClient', () => ({ + api: { invoke: invokeMock }, +})); + +import { ExternalSourceApiError } from './ExternalSourcesAPI'; +import { externalHooksAPI } from './ExternalHooksAPI'; + +const validSnapshot = { + schemaVersion: 1, + discoveryPending: false, + providers: [{ + providerId: 'claude-code.hooks', + ecosystemId: 'claude-code', + displayName: 'Claude Code Hooks', + }], + sources: [{ + key: { providerId: 'claude-code.hooks', sourceId: 'project-settings' }, + ecosystemId: 'claude-code', + displayName: 'Claude Code project Hooks', + sourceKind: 'settings', + scope: 'project', + locationHint: '.claude/settings.json', + health: 'available', + contentVersion: 'v1', + diagnostics: [], + }], + entries: [{ + stableKey: 'claude-code-project-pre-tool-use-0', + source: { providerId: 'claude-code.hooks', sourceId: 'project-settings' }, + nativeEvent: 'PreToolUse', + matcher: { kind: 'pattern', display: 'Bash|Read' }, + handlerKind: 'command', + projectionStatus: 'mapped', + nativeActivation: 'unknown', + mapping: { hookPoint: 'tool_before' }, + contentVersion: 'v1', + }], + staleProviderIds: [], + failedProviderIds: [], + diagnostics: [], +}; + +describe('ExternalHooksAPI', () => { + beforeEach(() => invokeMock.mockReset()); + + it('uses the structured Hook request and returns the runtime-free catalog', async () => { + invokeMock.mockResolvedValue(validSnapshot); + + await expect(externalHooksAPI.getCatalog(' D:/workspace/project ', true)) + .resolves.toEqual(validSnapshot); + expect(invokeMock).toHaveBeenCalledWith('get_external_hook_catalog', { + request: { workspacePath: 'D:/workspace/project', forceRefresh: true }, + }); + }); + + it('fails closed when a Host injects executable handler data into the catalog', async () => { + invokeMock.mockResolvedValue({ + ...validSnapshot, + entries: [{ ...validSnapshot.entries[0], command: 'curl secret.example' }], + }); + + await expect(externalHooksAPI.getCatalog()).rejects.toMatchObject({ + code: 'invalid_response', + }); + }); + + it('fails closed for unknown v1 enum values and invented Hook mappings', async () => { + invokeMock.mockResolvedValue({ + ...validSnapshot, + entries: [{ + ...validSnapshot.entries[0], + projectionStatus: 'mapped_elsewhere', + mapping: { hookPoint: 'session_start' }, + }], + }); + + await expect(externalHooksAPI.getCatalog()).rejects.toMatchObject({ + code: 'invalid_response', + }); + + invokeMock.mockResolvedValue({ + ...validSnapshot, + entries: [{ + ...validSnapshot.entries[0], + mapping: { hookPoint: 'session_start' }, + }], + }); + + await expect(externalHooksAPI.getCatalog()).rejects.toMatchObject({ + code: 'invalid_response', + }); + }); + + it('fails closed when source/provider identity facts disagree', async () => { + invokeMock.mockResolvedValue({ + ...validSnapshot, + providers: [{ ...validSnapshot.providers[0], ecosystemId: 'codex' }], + }); + + await expect(externalHooksAPI.getCatalog()).rejects.toMatchObject({ + code: 'invalid_response', + }); + }); + + it('fails closed when provider status references an unknown or conflicting provider', async () => { + invokeMock.mockResolvedValue({ + ...validSnapshot, + failedProviderIds: ['unknown.hooks'], + }); + await expect(externalHooksAPI.getCatalog()).rejects.toMatchObject({ + code: 'invalid_response', + }); + + invokeMock.mockResolvedValue({ + ...validSnapshot, + staleProviderIds: ['claude-code.hooks'], + failedProviderIds: ['claude-code.hooks'], + }); + await expect(externalHooksAPI.getCatalog()).rejects.toMatchObject({ + code: 'invalid_response', + }); + }); + + it('fails closed for duplicate identities and diagnostics with the wrong source', async () => { + invokeMock.mockResolvedValue({ + ...validSnapshot, + sources: [validSnapshot.sources[0], validSnapshot.sources[0]], + }); + await expect(externalHooksAPI.getCatalog()).rejects.toMatchObject({ + code: 'invalid_response', + }); + + invokeMock.mockResolvedValue({ + ...validSnapshot, + entries: [validSnapshot.entries[0], validSnapshot.entries[0]], + }); + await expect(externalHooksAPI.getCatalog()).rejects.toMatchObject({ + code: 'invalid_response', + }); + + invokeMock.mockResolvedValue({ + ...validSnapshot, + sources: [{ + ...validSnapshot.sources[0], + diagnostics: [{ + severity: 'warning', + assetKind: 'hook', + code: 'claude.hook.invalid', + message: 'invalid source identity', + source: { providerId: 'claude-code.hooks', sourceId: 'another-source' }, + }], + }], + }); + await expect(externalHooksAPI.getCatalog()).rejects.toMatchObject({ + code: 'invalid_response', + }); + }); + + it('fails closed when source diagnostics exceed the Rust contract limit', async () => { + invokeMock.mockResolvedValue({ + ...validSnapshot, + sources: [{ + ...validSnapshot.sources[0], + diagnostics: Array.from({ length: 257 }, (_, index) => ({ + severity: 'warning', + assetKind: 'hook', + code: `claude.hook.issue_${index}`, + message: `issue ${index}`, + source: validSnapshot.sources[0].key, + })), + }], + }); + + await expect(externalHooksAPI.getCatalog()).rejects.toMatchObject({ + code: 'invalid_response', + }); + }); +}); diff --git a/src/web-ui/src/infrastructure/api/service-api/ExternalHooksAPI.ts b/src/web-ui/src/infrastructure/api/service-api/ExternalHooksAPI.ts new file mode 100644 index 0000000000..fba586e6a9 --- /dev/null +++ b/src/web-ui/src/infrastructure/api/service-api/ExternalHooksAPI.ts @@ -0,0 +1,368 @@ +import { + ExternalSourceApiError, + invokeExternalSourceCommand, + normalizeOptionalWorkspacePath, +} from './ExternalSourcesAPI'; + +export interface ExternalHookSourceKey { + providerId: string; + sourceId: string; +} + +export interface ExternalHookDiagnostic { + severity: 'info' | 'warning' | 'error'; + assetKind: 'hook'; + code: string; + message: string; + source?: ExternalHookSourceKey; +} + +export interface ExternalHookProviderIdentity { + providerId: string; + ecosystemId: string; + displayName: string; +} + +export interface ExternalHookSource { + key: ExternalHookSourceKey; + ecosystemId: string; + displayName: string; + sourceKind: 'settings' | 'plugin_file' | 'package_declaration' | 'hooks_file' | 'inline_configuration'; + scope: 'user_global' | 'project' | 'workspace_local' | 'remote_user' | 'remote_project'; + locationHint: string; + health: 'available' | 'partial' | 'degraded' | 'unavailable'; + contentVersion: string; + diagnostics: ExternalHookDiagnostic[]; +} + +export type ExternalHookMatcher = + | { kind: 'any' } + | { kind: 'pattern'; display: string } + | { kind: 'dynamic' } + | { kind: 'unavailable' }; + +export interface ExternalHookCatalogEntry { + stableKey: string; + source: ExternalHookSourceKey; + nativeEvent: string; + matcher: ExternalHookMatcher; + handlerKind: 'function' | 'command' | 'http' | 'mcp_tool' | 'prompt' | 'agent'; + projectionStatus: 'mapped' | 'native_only' | 'opaque'; + nativeActivation: 'disabled' | 'unsupported' | 'unknown'; + mapping?: { hookPoint: 'tool_before' | 'tool_after' }; + contentVersion: string; +} + +export interface ExternalHookCatalogSnapshot { + schemaVersion: 1; + discoveryPending: boolean; + providers: ExternalHookProviderIdentity[]; + sources: ExternalHookSource[]; + entries: ExternalHookCatalogEntry[]; + staleProviderIds: string[]; + failedProviderIds: string[]; + diagnostics: ExternalHookDiagnostic[]; +} + +const MAX_CATALOG_ITEMS = 8192; +const MAX_PROVIDER_ITEMS = 2048; +const MAX_SOURCE_DIAGNOSTICS = 256; + +function enumString( + value: unknown, + allowed: T, +): T[number] { + const candidate = boundedString(value, 64); + if (!allowed.includes(candidate)) invalidResponse(); + return candidate; +} + +function invalidResponse(): never { + throw new ExternalSourceApiError( + 'invalid_response', + 'The Host returned an invalid external Hook catalog', + false, + ); +} + +function exactRecord( + value: unknown, + required: readonly string[], + optional: readonly string[] = [], +): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) invalidResponse(); + const record = value as Record; + const allowed = new Set([...required, ...optional]); + if (!required.every((key) => Object.prototype.hasOwnProperty.call(record, key)) + || Object.keys(record).some((key) => !allowed.has(key))) { + invalidResponse(); + } + return record; +} + +function boundedString(value: unknown, maxLength = 512): string { + if (typeof value !== 'string' + || value.length === 0 + || value.length > maxLength + || Array.from(value).some((character) => { + const codePoint = character.codePointAt(0); + return codePoint !== undefined && (codePoint <= 0x1f || codePoint === 0x7f); + })) { + invalidResponse(); + } + return value; +} + +function boundedArray(value: unknown): unknown[] { + if (!Array.isArray(value) || value.length > MAX_CATALOG_ITEMS) invalidResponse(); + return value; +} + +function sourceKey(value: unknown): ExternalHookSourceKey { + const record = exactRecord(value, ['providerId', 'sourceId']); + return { + providerId: boundedString(record.providerId, 160), + sourceId: boundedString(record.sourceId, 160), + }; +} + +function provider(value: unknown): ExternalHookProviderIdentity { + const record = exactRecord(value, ['providerId', 'ecosystemId', 'displayName']); + return { + providerId: boundedString(record.providerId, 160), + ecosystemId: boundedString(record.ecosystemId, 160), + displayName: boundedString(record.displayName), + }; +} + +function diagnostic(value: unknown): ExternalHookDiagnostic { + const record = exactRecord( + value, + ['severity', 'assetKind', 'code', 'message'], + ['source'], + ); + return { + severity: enumString(record.severity, ['info', 'warning', 'error'] as const), + assetKind: enumString(record.assetKind, ['hook'] as const), + code: boundedString(record.code, 160), + message: boundedString(record.message, 4096), + ...(record.source === undefined ? {} : { source: sourceKey(record.source) }), + }; +} + +function diagnostics(value: unknown, maxItems = MAX_CATALOG_ITEMS): ExternalHookDiagnostic[] { + if (value === undefined) return []; + const items = boundedArray(value); + if (items.length > maxItems) invalidResponse(); + return items.map(diagnostic); +} + +function matcher(value: unknown): ExternalHookMatcher { + const tagged = exactRecord(value, ['kind'], ['display']); + switch (tagged.kind) { + case 'any': + case 'dynamic': + case 'unavailable': + if (tagged.display !== undefined) invalidResponse(); + return { kind: tagged.kind }; + case 'pattern': + return { kind: 'pattern', display: boundedString(tagged.display) }; + default: + return invalidResponse(); + } +} + +function source(value: unknown): ExternalHookSource { + const record = exactRecord(value, [ + 'key', + 'ecosystemId', + 'displayName', + 'sourceKind', + 'scope', + 'locationHint', + 'health', + 'contentVersion', + ], ['diagnostics']); + const key = sourceKey(record.key); + const sourceDiagnostics = diagnostics(record.diagnostics, MAX_SOURCE_DIAGNOSTICS); + if (sourceDiagnostics.some((item) => item.source !== undefined + && (item.source.providerId !== key.providerId || item.source.sourceId !== key.sourceId))) { + invalidResponse(); + } + return { + key, + ecosystemId: boundedString(record.ecosystemId, 160), + displayName: boundedString(record.displayName), + sourceKind: enumString(record.sourceKind, [ + 'settings', + 'plugin_file', + 'package_declaration', + 'hooks_file', + 'inline_configuration', + ] as const), + scope: enumString(record.scope, [ + 'user_global', + 'project', + 'workspace_local', + 'remote_user', + 'remote_project', + ] as const), + locationHint: boundedString(record.locationHint), + health: enumString(record.health, [ + 'available', + 'partial', + 'degraded', + 'unavailable', + ] as const), + contentVersion: boundedString(record.contentVersion, 160), + diagnostics: sourceDiagnostics, + }; +} + +function entry(value: unknown): ExternalHookCatalogEntry { + const record = exactRecord(value, [ + 'stableKey', + 'source', + 'nativeEvent', + 'matcher', + 'handlerKind', + 'projectionStatus', + 'nativeActivation', + 'contentVersion', + ], ['mapping']); + const projectionStatus = enumString(record.projectionStatus, [ + 'mapped', + 'native_only', + 'opaque', + ] as const); + let mapping: ExternalHookCatalogEntry['mapping']; + if (record.mapping !== undefined) { + const mapped = exactRecord(record.mapping, ['hookPoint']); + mapping = { + hookPoint: enumString(mapped.hookPoint, ['tool_before', 'tool_after'] as const), + }; + } + if ((projectionStatus === 'mapped') !== Boolean(mapping)) invalidResponse(); + return { + stableKey: boundedString(record.stableKey, 160), + source: sourceKey(record.source), + nativeEvent: boundedString(record.nativeEvent, 160), + matcher: matcher(record.matcher), + handlerKind: enumString(record.handlerKind, [ + 'function', + 'command', + 'http', + 'mcp_tool', + 'prompt', + 'agent', + ] as const), + projectionStatus, + nativeActivation: enumString(record.nativeActivation, [ + 'disabled', + 'unsupported', + 'unknown', + ] as const), + ...(mapping ? { mapping } : {}), + contentVersion: boundedString(record.contentVersion, 160), + }; +} + +function normalizeCatalog(value: unknown): ExternalHookCatalogSnapshot { + const record = exactRecord(value, [ + 'schemaVersion', + 'discoveryPending', + 'providers', + 'sources', + 'entries', + ], ['staleProviderIds', 'failedProviderIds', 'diagnostics']); + if (record.schemaVersion !== 1 || typeof record.discoveryPending !== 'boolean') { + invalidResponse(); + } + const providers = boundedArray(record.providers).map(provider); + const providerIds = new Set(); + if (providers.some((item) => { + if (providerIds.has(item.providerId)) return true; + providerIds.add(item.providerId); + return false; + })) invalidResponse(); + const sources = boundedArray(record.sources).map(source); + const entries = boundedArray(record.entries).map(entry); + const sourceKeys = new Set(); + if (sources.some((item) => { + const key = `${item.key.providerId}\u0000${item.key.sourceId}`; + if (sourceKeys.has(key)) return true; + sourceKeys.add(key); + return false; + })) invalidResponse(); + const entryKeys = new Set(); + if (entries.some((item) => { + if (entryKeys.has(item.stableKey)) return true; + entryKeys.add(item.stableKey); + return false; + })) invalidResponse(); + const providerSourceCounts = new Map(); + const providerEntryCounts = new Map(); + for (const item of sources) { + providerSourceCounts.set( + item.key.providerId, + (providerSourceCounts.get(item.key.providerId) ?? 0) + 1, + ); + } + for (const item of entries) { + providerEntryCounts.set( + item.source.providerId, + (providerEntryCounts.get(item.source.providerId) ?? 0) + 1, + ); + } + if (sources.some((item) => { + const identity = providers.find((candidate) => candidate.providerId === item.key.providerId); + return !identity || identity.ecosystemId !== item.ecosystemId; + }) || entries.some((item) => !sourceKeys.has( + `${item.source.providerId}\u0000${item.source.sourceId}`, + )) || [...providerSourceCounts.values(), ...providerEntryCounts.values()] + .some((count) => count > MAX_PROVIDER_ITEMS)) { + invalidResponse(); + } + const staleProviderIds = record.staleProviderIds === undefined + ? [] + : boundedArray(record.staleProviderIds).map((item) => boundedString(item, 160)); + const failedProviderIds = record.failedProviderIds === undefined + ? [] + : boundedArray(record.failedProviderIds).map((item) => boundedString(item, 160)); + const staleSet = new Set(staleProviderIds); + const failedSet = new Set(failedProviderIds); + if (staleSet.size !== staleProviderIds.length + || failedSet.size !== failedProviderIds.length + || staleProviderIds.some((id) => !providerIds.has(id) || failedSet.has(id)) + || failedProviderIds.some((id) => !providerIds.has(id))) { + invalidResponse(); + } + const catalogDiagnostics = diagnostics(record.diagnostics); + if (catalogDiagnostics.some((item) => item.source !== undefined && !sourceKeys.has( + `${item.source.providerId}\u0000${item.source.sourceId}`, + ))) { + invalidResponse(); + } + return { + schemaVersion: 1, + discoveryPending: record.discoveryPending, + providers, + sources, + entries, + staleProviderIds, + failedProviderIds, + diagnostics: catalogDiagnostics, + }; +} + +export const externalHooksAPI = { + async getCatalog(workspacePath?: string, forceRefresh = false) { + const response = await invokeExternalSourceCommand('get_external_hook_catalog', { + request: { + workspacePath: normalizeOptionalWorkspacePath(workspacePath), + forceRefresh, + }, + }); + return normalizeCatalog(response); + }, +}; diff --git a/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts b/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts index c4c9589a53..bae5c3a45d 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts @@ -944,7 +944,10 @@ function parseOperationError(value: unknown, visited = new Set()): Exte return null; } -async function invokeExternal(command: string, args: Record): Promise { +export async function invokeExternalSourceCommand( + command: string, + args: Record, +): Promise { try { return await api.invoke(command, args); } catch (error) { @@ -990,7 +993,7 @@ async function invokeSnapshot( command: string, args: Record, ): Promise { - await invokeExternal(command, args); + await invokeExternalSourceCommand(command, args); const request = args.request && typeof args.request === 'object' ? args.request as Record : {}; @@ -1006,7 +1009,7 @@ async function invokeSurfaceSnapshot( command: string, args: Record, ): Promise { - return normalizeSurfaceSnapshot(await invokeExternal(command, args)); + return normalizeSurfaceSnapshot(await invokeExternalSourceCommand(command, args)); } function legacySurfaceSnapshot(catalog: ExternalSourceCatalogSnapshot): ExternalSourceSurfaceSnapshot { @@ -1042,7 +1045,7 @@ async function invokeCompatibleSurfaceSnapshot( if (!(error instanceof ExternalSourceApiError) || error.code !== 'incompatible_version') { throw error; } - const catalog = normalizeSnapshot(await invokeExternal( + const catalog = normalizeSnapshot(await invokeExternalSourceCommand( 'get_external_source_snapshot', args, )); @@ -1050,8 +1053,11 @@ async function invokeCompatibleSurfaceSnapshot( } } -function normalizeOptionalWorkspacePath(workspacePath: string | undefined): string | undefined { - return workspacePath?.trim() ? workspacePath : undefined; +export function normalizeOptionalWorkspacePath( + workspacePath: string | undefined, +): string | undefined { + const normalized = workspacePath?.trim(); + return normalized || undefined; } let operationSequence = 0; @@ -1081,7 +1087,7 @@ export const externalSourcesAPI = { }, revealSourceLocation(workspacePath: string | undefined, sourceKey: string) { - return invokeExternal('reveal_external_source_location', { + return invokeExternalSourceCommand('reveal_external_source_location', { request: { workspacePath: normalizeOptionalWorkspacePath(workspacePath), sourceKey, diff --git a/src/web-ui/src/infrastructure/config/components/ExternalHooksPanel.scss b/src/web-ui/src/infrastructure/config/components/ExternalHooksPanel.scss new file mode 100644 index 0000000000..f7ab9f8669 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/ExternalHooksPanel.scss @@ -0,0 +1,184 @@ +.bitfun-external-hooks { + &__body { + padding: var(--size-gap-4); + } + + &__state, + &__notice { + display: flex; + align-items: center; + gap: var(--size-gap-2); + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + line-height: var(--line-height-base); + } + + &__notice { + padding: var(--size-gap-3); + border-radius: var(--size-radius-sm); + background: var(--element-bg-subtle); + } + + &__spinner { + animation: bitfun-external-hooks-spin 1s linear infinite; + } + + &__ecosystems, + &__sources { + display: flex; + flex-direction: column; + gap: var(--size-gap-3); + } + + &__ecosystem { + border: 1px solid var(--border-subtle); + border-radius: var(--size-radius-sm); + background: var(--element-bg-base); + overflow: hidden; + + > summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--size-gap-3); + padding: var(--size-gap-3); + color: var(--color-text-primary); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + cursor: pointer; + } + } + + &__counts, + &__location, + &__entry-detail, + &__empty-source, + &__footnote { + color: var(--color-text-secondary); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-normal); + } + + &__sources { + padding: 0 var(--size-gap-3) var(--size-gap-3); + } + + &__source { + padding: var(--size-gap-3); + border-radius: var(--size-radius-sm); + background: var(--element-bg-subtle); + } + + &__source-heading, + &__entry-main, + &__entry-detail { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--size-gap-3); + } + + &__source-name { + color: var(--color-text-primary); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + } + + &__location { + margin-top: var(--size-gap-1); + overflow-wrap: anywhere; + } + + &__health { + flex-shrink: 0; + padding: var(--size-gap-1) var(--size-gap-2); + border-radius: var(--size-radius-lg); + background: var(--element-bg-base); + color: var(--color-text-secondary); + font-size: var(--font-size-xs); + } + + &__entries, + &__diagnostics, + &__catalog-diagnostics ul { + margin: var(--size-gap-3) 0 0; + padding: 0; + list-style: none; + } + + &__entries li { + padding: var(--size-gap-2) 0; + border-top: 1px solid var(--border-subtle); + } + + &__entry-main { + font-size: var(--font-size-xs); + + code { + color: var(--color-text-primary); + font-family: var(--font-family-mono); + } + } + + &__entry-detail { + margin-top: var(--size-gap-1); + } + + &__empty-source, + &__diagnostics, + &__catalog-diagnostics, + &__footnote { + margin-top: var(--size-gap-3); + } + + &__diagnostics, + &__catalog-diagnostics { + color: var(--color-text-secondary); + font-size: var(--font-size-xs); + } + + &__diagnostics li, + &__catalog-diagnostics li { + margin-top: var(--size-gap-1); + } + + &__technical-detail { + margin-top: var(--size-gap-1); + + > summary { + cursor: pointer; + } + + > code, + > span { + display: block; + margin-top: var(--size-gap-1); + overflow-wrap: anywhere; + } + + > code { + font-family: var(--font-family-mono); + } + } +} + +@keyframes bitfun-external-hooks-spin { + to { transform: rotate(360deg); } +} + +@media (prefers-reduced-motion: reduce) { + .bitfun-external-hooks__spinner { + animation: none; + } +} + +@container config-panel (max-width: 520px) { + .bitfun-external-hooks { + &__ecosystem > summary, + &__source-heading, + &__entry-detail { + align-items: flex-start; + flex-direction: column; + } + } +} diff --git a/src/web-ui/src/infrastructure/config/components/ExternalHooksPanel.test.tsx b/src/web-ui/src/infrastructure/config/components/ExternalHooksPanel.test.tsx new file mode 100644 index 0000000000..4e811a1ef4 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/ExternalHooksPanel.test.tsx @@ -0,0 +1,395 @@ +// @vitest-environment jsdom + +import React, { act } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createRoot, type Root } from 'react-dom/client'; +import ExternalHooksPanel from './ExternalHooksPanel'; + +const getCatalogMock = vi.hoisted(() => vi.fn()); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, params?: Record) => ( + params ? `${key}:${JSON.stringify(params)}` : key + ), + }), +})); + +vi.mock('@/component-library', () => ({ + Button: ({ children, disabled, onClick, ...props }: React.ButtonHTMLAttributes) => ( + + ), + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +vi.mock('@/infrastructure/api/service-api/ExternalHooksAPI', () => ({ + externalHooksAPI: { getCatalog: getCatalogMock }, +})); + +vi.mock('./common', () => ({ + ConfigPageSection: ({ children, title, description, extra }: { + children: React.ReactNode; + title: string; + description?: React.ReactNode; + extra?: React.ReactNode; + }) => ( +
+

{title}

+
{description}
+ {extra} + {children} +
+ ), +})); + +const snapshot = { + schemaVersion: 1, + discoveryPending: false, + providers: [{ + providerId: 'claude-code.hooks', + ecosystemId: 'claude-code', + displayName: 'Claude Code Hooks', + }], + sources: [{ + key: { providerId: 'claude-code.hooks', sourceId: 'project-settings' }, + ecosystemId: 'claude-code', + displayName: 'Claude Code project Hooks', + sourceKind: 'settings', + scope: 'project', + locationHint: '.claude/settings.json', + health: 'available', + contentVersion: 'v1', + diagnostics: [], + }], + entries: [{ + stableKey: 'pre-tool-use', + source: { providerId: 'claude-code.hooks', sourceId: 'project-settings' }, + nativeEvent: 'PreToolUse', + matcher: { kind: 'pattern', display: 'Bash|Read' }, + handlerKind: 'command', + projectionStatus: 'mapped', + nativeActivation: 'unknown', + mapping: { hookPoint: 'tool_before' }, + contentVersion: 'v1', + }, { + stableKey: 'notification', + source: { providerId: 'claude-code.hooks', sourceId: 'project-settings' }, + nativeEvent: 'Notification', + matcher: { kind: 'any' }, + handlerKind: 'command', + projectionStatus: 'native_only', + nativeActivation: 'unknown', + contentVersion: 'v1', + }, { + stableKey: 'opaque', + source: { providerId: 'claude-code.hooks', sourceId: 'project-settings' }, + nativeEvent: '', + matcher: { kind: 'dynamic' }, + handlerKind: 'function', + projectionStatus: 'opaque', + nativeActivation: 'unknown', + contentVersion: 'v1', + }], + staleProviderIds: [], + failedProviderIds: [], + diagnostics: [], +}; + +describe('ExternalHooksPanel', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }) + .IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + getCatalogMock.mockReset(); + getCatalogMock.mockResolvedValue(snapshot); + }); + + afterEach(() => { + vi.useRealTimers(); + act(() => root.unmount()); + container.remove(); + }); + + it('renders mapped and native-only Hooks without executable handler details', async () => { + await act(async () => { + root.render(); + await Promise.resolve(); + }); + + expect(container.textContent).toContain('PreToolUse'); + expect(container.textContent).toContain('hooks.projection.tool_before'); + expect(container.textContent).toContain('Notification'); + expect(container.textContent).toContain('hooks.projection.nativeOnly'); + expect(container.textContent).toContain('hooks.projection.opaque'); + expect(container.textContent).toContain('hooks.matcherValue.all'); + expect(container.textContent).not.toContain('curl'); + expect(getCatalogMock).toHaveBeenCalledWith('D:/workspace/project', true); + }); + + it('shows source diagnostics once and keeps provider diagnostics separate', async () => { + const sourceDiagnostic = { + severity: 'warning', + assetKind: 'hook', + code: 'claude.hook.partial', + message: 'source-only diagnostic', + source: snapshot.sources[0].key, + }; + getCatalogMock.mockResolvedValue({ + ...snapshot, + sources: [{ ...snapshot.sources[0], diagnostics: [sourceDiagnostic] }], + diagnostics: [ + sourceDiagnostic, + { + severity: 'info', + assetKind: 'hook', + code: 'claude.hook.coverage', + message: 'provider-wide diagnostic', + }, + ], + }); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + + expect(container.textContent?.match(/source-only diagnostic/g)).toHaveLength(1); + expect(container.textContent?.match(/provider-wide diagnostic/g)).toHaveLength(1); + }); + + it('shows a remote-specific unsupported state without reading local Hooks', async () => { + await act(async () => { + root.render( + , + ); + await Promise.resolve(); + }); + + expect(container.textContent).toContain('hooks.unsupported.remote'); + expect(getCatalogMock).not.toHaveBeenCalled(); + }); + + it('refreshes explicitly without exposing edit or execute controls', async () => { + await act(async () => { + root.render(); + await Promise.resolve(); + }); + const refresh = container.querySelector('button[aria-label="hooks.refresh"]'); + expect(refresh).not.toBeNull(); + await act(async () => { + refresh?.click(); + await Promise.resolve(); + }); + + expect(getCatalogMock).toHaveBeenLastCalledWith('D:/workspace/project', true); + expect(container.querySelector('[data-hook-action="edit"]')).toBeNull(); + expect(container.querySelector('[data-hook-action="execute"]')).toBeNull(); + }); + + it('responds to the shared page refresh epoch', async () => { + await act(async () => { + root.render(); + await Promise.resolve(); + }); + getCatalogMock.mockClear(); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + + expect(getCatalogMock).toHaveBeenCalledWith('D:/workspace/project', true); + }); + + it('bounds mounted Hook entries until the user asks for more', async () => { + const entries = Array.from({ length: 250 }, (_, index) => ({ + ...snapshot.entries[0], + stableKey: `entry-${index}`, + nativeEvent: `Event${index}`, + })); + getCatalogMock.mockResolvedValue({ ...snapshot, entries }); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + + expect(container.querySelectorAll('.bitfun-external-hooks__entries > li')).toHaveLength(100); + const showMore = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent?.includes('hooks.showMoreEntries'), + ); + expect(showMore).toBeDefined(); + await act(async () => showMore?.dispatchEvent(new MouseEvent('click', { bubbles: true }))); + expect(container.querySelectorAll('.bitfun-external-hooks__entries > li')).toHaveLength(200); + }); + + it('uses one entry budget across every visible source in a provider', async () => { + const sources = Array.from({ length: 20 }, (_, sourceIndex) => ({ + ...snapshot.sources[0], + key: { providerId: 'claude-code.hooks', sourceId: `source-${sourceIndex}` }, + displayName: `Source ${sourceIndex}`, + })); + const entries = sources.flatMap((source, sourceIndex) => ( + Array.from({ length: 100 }, (_, entryIndex) => ({ + ...snapshot.entries[0], + stableKey: `entry-${sourceIndex}-${entryIndex}`, + source: source.key, + nativeEvent: `Event${sourceIndex}_${entryIndex}`, + })) + )); + getCatalogMock.mockResolvedValue({ ...snapshot, sources, entries }); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + + expect(container.querySelectorAll('.bitfun-external-hooks__entries > li')).toHaveLength(100); + expect(container.querySelector('button[aria-label*="Claude Code Hooks"]')).not.toBeNull(); + }); + + it('keeps empty providers visible and announces the final empty state', async () => { + getCatalogMock.mockResolvedValue({ ...snapshot, sources: [], entries: [] }); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + + expect(container.textContent).toContain('Claude Code Hooks'); + expect(container.textContent).toContain('hooks.providerEmpty'); + expect(container.querySelector('[aria-live="polite"]')?.textContent).toContain('hooks.empty'); + }); + + it('distinguishes failed empty providers from successful empty discovery', async () => { + getCatalogMock.mockResolvedValue({ + ...snapshot, + sources: [], + entries: [], + failedProviderIds: ['claude-code.hooks'], + }); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + + expect(container.textContent).toContain('hooks.providerFailed'); + expect(container.textContent).toContain('hooks.health.failed'); + expect(container.textContent).not.toContain('hooks.providerEmpty'); + }); + + it('does not announce a stale empty catalog as a successful empty discovery', async () => { + getCatalogMock.mockResolvedValue({ + ...snapshot, + sources: [], + entries: [], + staleProviderIds: ['claude-code.hooks'], + }); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + + expect(container.textContent).toContain('hooks.providerStaleEmpty'); + expect(container.textContent).toContain('hooks.health.stale'); + expect(container.textContent).not.toContain('hooks.empty'); + }); + + it('bounds provider and catalog diagnostics until the user asks for more', async () => { + const providerDiagnostics = Array.from({ length: 30 }, (_, index) => ({ + severity: 'warning', + assetKind: 'hook', + code: `claude.hook.source_${index}`, + message: `source diagnostic ${index}`, + source: snapshot.sources[0].key, + })); + const catalogDiagnostics = Array.from({ length: 30 }, (_, index) => ({ + severity: 'warning', + assetKind: 'hook', + code: `claude.hook.catalog_${index}`, + message: `catalog diagnostic ${index}`, + })); + getCatalogMock.mockResolvedValue({ + ...snapshot, + sources: [{ ...snapshot.sources[0], diagnostics: providerDiagnostics }], + diagnostics: [...providerDiagnostics, ...catalogDiagnostics], + }); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + + expect(container.querySelectorAll('.bitfun-external-hooks__diagnostics > li')).toHaveLength(20); + expect(container.querySelectorAll('.bitfun-external-hooks__catalog-diagnostics li')).toHaveLength(20); + expect(Array.from(container.querySelectorAll('button')).filter( + (button) => button.textContent?.includes('hooks.showMoreDiagnostics'), + )).toHaveLength(2); + }); + + it('polls the cached snapshot until deferred discovery completes', async () => { + vi.useFakeTimers(); + getCatalogMock + .mockResolvedValueOnce({ ...snapshot, discoveryPending: true, sources: [], entries: [] }) + .mockResolvedValueOnce({ ...snapshot, discoveryPending: true, sources: [], entries: [] }) + .mockResolvedValueOnce(snapshot); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + expect(container.textContent).toContain('hooks.pending'); + + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + await Promise.resolve(); + }); + + expect(container.textContent).toContain('hooks.pending'); + + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + await Promise.resolve(); + }); + + expect(getCatalogMock).toHaveBeenLastCalledWith('D:/workspace/project', false); + expect(container.textContent).toContain('PreToolUse'); + }); + + it('stops failed polling and allows an explicit refresh to recover', async () => { + vi.useFakeTimers(); + getCatalogMock + .mockResolvedValueOnce({ ...snapshot, discoveryPending: true, sources: [], entries: [] }) + .mockRejectedValueOnce(new Error('transport unavailable')) + .mockResolvedValueOnce(snapshot); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + await Promise.resolve(); + }); + + expect(container.textContent).toContain('hooks.pollInterrupted'); + expect(container.textContent).not.toContain('hooks.staleAfterRefresh'); + expect(container.textContent).not.toContain('hooks.providerEmpty'); + const refresh = container.querySelector('button[aria-label="hooks.refresh"]'); + expect(refresh?.disabled).toBe(false); + await act(async () => { + refresh?.click(); + await Promise.resolve(); + }); + + expect(getCatalogMock).toHaveBeenLastCalledWith('D:/workspace/project', true); + expect(container.textContent).toContain('PreToolUse'); + }); +}); diff --git a/src/web-ui/src/infrastructure/config/components/ExternalHooksPanel.tsx b/src/web-ui/src/infrastructure/config/components/ExternalHooksPanel.tsx new file mode 100644 index 0000000000..d4a935c5e2 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/ExternalHooksPanel.tsx @@ -0,0 +1,517 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { TFunction } from 'i18next'; +import { AlertTriangle, CircleDashed, RefreshCw } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { Button, Tooltip } from '@/component-library'; +import { ExternalSourceApiError } from '@/infrastructure/api/service-api/ExternalSourcesAPI'; +import { + externalHooksAPI, + type ExternalHookCatalogEntry, + type ExternalHookCatalogSnapshot, + type ExternalHookDiagnostic, + type ExternalHookProviderIdentity, + type ExternalHookSource, +} from '@/infrastructure/api/service-api/ExternalHooksAPI'; +import { createLogger } from '@/shared/utils/logger'; +import { ConfigPageSection } from './common'; +import './ExternalHooksPanel.scss'; + +const logger = createLogger('ExternalHooksPanel'); +const INITIAL_SOURCE_LIMIT = 20; +const SOURCE_PAGE_SIZE = 20; +const INITIAL_ENTRY_LIMIT = 100; +const ENTRY_PAGE_SIZE = 100; +const INITIAL_DIAGNOSTIC_LIMIT = 20; +const DIAGNOSTIC_PAGE_SIZE = 20; + +export interface ExternalHooksPanelProps { + workspacePath?: string; + unsupportedReason?: 'remote' | 'peer'; + refreshEpoch?: number; +} + +function sourceKey(source: ExternalHookSource['key']): string { + return `${source.providerId}\u0000${source.sourceId}`; +} + +function sourceScopeLabel(source: ExternalHookSource, t: TFunction): string { + return source.scope === 'workspace_local' + ? t('shared:features.workspace') + : t(`hooks.scope.${source.scope}`); +} + +function matcherLabel(entry: ExternalHookCatalogEntry, t: TFunction): string { + switch (entry.matcher.kind) { + case 'any': return t('hooks.matcherValue.all'); + case 'pattern': return entry.matcher.display; + case 'dynamic': return t('hooks.matcherValue.dynamic'); + case 'unavailable': return t('hooks.matcherValue.unavailable'); + default: return t('hooks.matcherValue.unknown'); + } +} + +function projectionLabel(entry: ExternalHookCatalogEntry, t: TFunction): string { + if (entry.projectionStatus === 'opaque') return t('hooks.projection.opaque'); + if (!entry.mapping) return t('hooks.projection.nativeOnly'); + return t(`hooks.projection.${entry.mapping.hookPoint}`); +} + +function diagnosticCategory(code: string): string { + if (code.endsWith('all_disabled')) return 'nativeDisabled'; + if (code.endsWith('activation_not_evaluated')) return 'activationUnknown'; + if (code.endsWith('coverage_static_only') || code.endsWith('package_declared_only')) { + return 'staticCoverage'; + } + if (code.endsWith('registration_opaque')) return 'opaque'; + if (code.includes('limit') || code.endsWith('too_large')) return 'inspectionLimit'; + if (code.includes('parse') || code.includes('invalid')) return 'invalidConfiguration'; + if (code.includes('unreadable') || code.includes('failed') || code.includes('unavailable')) { + return 'unavailable'; + } + return 'generic'; +} + +function HookDiagnosticItem({ diagnostic, t }: { + diagnostic: ExternalHookDiagnostic; + t: TFunction; +}) { + return ( +
  • + {t(`hooks.diagnosticCategory.${diagnosticCategory(diagnostic.code)}`, { + code: diagnostic.code, + })} +
    + {t('hooks.technicalDetails')} + {diagnostic.code} + {diagnostic.message} +
    +
  • + ); +} + +function HookSourceCard({ + source, + entries, + diagnostics, + totalEntries, + stale, + t, +}: { + source: ExternalHookSource; + entries: ExternalHookCatalogEntry[]; + diagnostics: ExternalHookDiagnostic[]; + totalEntries: number; + stale: boolean; + t: TFunction; +}) { + return ( +
    +
    +
    +
    {source.displayName}
    +
    + {source.locationHint} + {' · '} + {sourceScopeLabel(source, t)} +
    +
    + + {t(stale ? 'hooks.health.stale' : `hooks.health.${source.health}`)} + +
    + {totalEntries === 0 ? ( +
    {t('hooks.noEntries')}
    + ) : entries.length === 0 ? ( +
    {t('hooks.entriesDeferred')}
    + ) : ( +
      + {entries.map((entry) => ( +
    • +
      + {entry.nativeEvent} + {t(`hooks.handler.${entry.handlerKind}`)} +
      +
      + {t('hooks.matcher', { matcher: matcherLabel(entry, t) })} + {projectionLabel(entry, t)} + {t(`hooks.activation.${entry.nativeActivation}`)} +
      +
    • + ))} +
    + )} + {diagnostics.length > 0 ? ( +
      + {diagnostics.map((diagnostic) => ( + + ))} +
    + ) : null} +
    + ); +} + +function HookProviderSection({ + provider, + sources, + entriesBySource, + staleProviderIds, + failedProviderIds, + discoveryPending, + initiallyOpen, + t, +}: { + provider: ExternalHookProviderIdentity; + sources: ExternalHookSource[]; + entriesBySource: Map; + staleProviderIds: string[]; + failedProviderIds: string[]; + discoveryPending: boolean; + initiallyOpen: boolean; + t: TFunction; +}) { + const [open, setOpen] = useState(initiallyOpen); + const [sourceLimit, setSourceLimit] = useState(INITIAL_SOURCE_LIMIT); + const [entryLimit, setEntryLimit] = useState(INITIAL_ENTRY_LIMIT); + const [diagnosticLimit, setDiagnosticLimit] = useState(INITIAL_DIAGNOSTIC_LIMIT); + useEffect(() => { + if (initiallyOpen) setOpen(true); + }, [initiallyOpen]); + const entries = sources.flatMap((source) => entriesBySource.get(sourceKey(source.key)) ?? []); + const mappedCount = entries.filter((entry) => entry.projectionStatus === 'mapped').length; + const visibleSources = sources.slice(0, sourceLimit); + let remainingEntryBudget = entryLimit; + let remainingDiagnosticBudget = diagnosticLimit; + const renderedSources = visibleSources.map((source) => { + const sourceEntries = entriesBySource.get(sourceKey(source.key)) ?? []; + const renderedEntries = sourceEntries.slice(0, remainingEntryBudget); + const renderedDiagnostics = source.diagnostics.slice(0, remainingDiagnosticBudget); + remainingEntryBudget -= renderedEntries.length; + remainingDiagnosticBudget -= renderedDiagnostics.length; + return { + source, + entries: renderedEntries, + diagnostics: renderedDiagnostics, + totalEntries: sourceEntries.length, + }; + }); + const visibleEntryCount = visibleSources.reduce( + (count, source) => count + (entriesBySource.get(sourceKey(source.key))?.length ?? 0), + 0, + ); + const renderedEntryCount = renderedSources.reduce((count, item) => count + item.entries.length, 0); + const visibleDiagnosticCount = visibleSources.reduce( + (count, source) => count + source.diagnostics.length, + 0, + ); + const renderedDiagnosticCount = renderedSources.reduce( + (count, item) => count + item.diagnostics.length, + 0, + ); + const stale = staleProviderIds.includes(provider.providerId); + const failed = failedProviderIds.includes(provider.providerId); + return ( +
    setOpen(event.currentTarget.open)} + > + + {provider.displayName} + + {t('hooks.summary', { hooks: entries.length, mapped: mappedCount, sources: sources.length })} + {failed + ? ` · ${t('hooks.health.failed')}` + : stale ? ` · ${t('hooks.health.stale')}` : null} + + + {open ? ( +
    + {sources.length === 0 && !discoveryPending ? ( +
    + {t(failed ? 'hooks.providerFailed' : stale ? 'hooks.providerStaleEmpty' : 'hooks.providerEmpty')} +
    + ) : null} + {renderedSources.map(({ + source, + entries: renderedEntries, + diagnostics: renderedDiagnostics, + totalEntries, + }) => ( + + ))} + {visibleSources.length < sources.length ? ( + + ) : null} + {renderedEntryCount < visibleEntryCount ? ( + + ) : null} + {renderedDiagnosticCount < visibleDiagnosticCount ? ( + + ) : null} +
    + ) : null} +
    + ); +} + +const ExternalHooksPanel: React.FC = ({ + workspacePath, + unsupportedReason, + refreshEpoch = 0, +}) => { + const { t } = useTranslation(['settings/external-sources', 'shared']); + const [snapshot, setSnapshot] = useState(null); + const [loading, setLoading] = useState(!unsupportedReason); + const [refreshing, setRefreshing] = useState(false); + const [errorCode, setErrorCode] = useState(null); + const [catalogDiagnosticLimit, setCatalogDiagnosticLimit] = useState(INITIAL_DIAGNOSTIC_LIMIT); + const requestSequence = useRef(0); + + const loadCatalog = useCallback(async (forceRefresh: boolean, showLoading = true) => { + if (unsupportedReason) return null; + const sequence = ++requestSequence.current; + if (forceRefresh) setRefreshing(true); + if (showLoading) setLoading(true); + try { + const next = await externalHooksAPI.getCatalog(workspacePath, forceRefresh); + if (sequence !== requestSequence.current) return null; + setSnapshot(next); + setErrorCode(null); + return next; + } catch (error) { + if (sequence !== requestSequence.current) return null; + const code = error instanceof ExternalSourceApiError ? error.code : 'internal'; + logger.warn('Failed to load external Hook catalog', { code }); + setErrorCode(code); + return null; + } finally { + if (sequence === requestSequence.current) { + if (showLoading) setLoading(false); + setRefreshing(false); + } + } + }, [unsupportedReason, workspacePath]); + + useEffect(() => { + requestSequence.current += 1; + setSnapshot(null); + setErrorCode(null); + setCatalogDiagnosticLimit(INITIAL_DIAGNOSTIC_LIMIT); + if (unsupportedReason) { + setLoading(false); + setRefreshing(false); + return; + } + void loadCatalog(true); + // `refreshEpoch` deliberately joins initial/workspace and header refreshes. + }, [loadCatalog, refreshEpoch, unsupportedReason]); + + useEffect(() => { + if (unsupportedReason || loading || errorCode || !snapshot?.discoveryPending) return undefined; + let cancelled = false; + let timer: number | undefined; + const poll = async () => { + const next = await loadCatalog(false, false); + if (!cancelled && next?.discoveryPending) { + timer = window.setTimeout(() => void poll(), 250); + } + }; + timer = window.setTimeout(() => void poll(), 250); + return () => { + cancelled = true; + if (timer !== undefined) window.clearTimeout(timer); + }; + }, [errorCode, loadCatalog, loading, snapshot?.discoveryPending, unsupportedReason]); + + const providers = useMemo(() => (snapshot?.providers ?? []).map((provider) => ({ + provider, + sources: snapshot?.sources.filter((source) => source.key.providerId === provider.providerId) ?? [], + })), [snapshot]); + + const entriesBySource = useMemo(() => { + const grouped = new Map(); + for (const entry of snapshot?.entries ?? []) { + const key = sourceKey(entry.source); + const entries = grouped.get(key) ?? []; + entries.push(entry); + grouped.set(key, entries); + } + return grouped; + }, [snapshot]); + + const catalogDiagnostics = useMemo( + () => snapshot?.diagnostics.filter((diagnostic) => diagnostic.source === undefined) ?? [], + [snapshot], + ); + const visibleCatalogDiagnostics = catalogDiagnostics.slice(0, catalogDiagnosticLimit); + const busy = loading + || refreshing + || (Boolean(snapshot?.discoveryPending) && !errorCode); + const firstProviderWithSources = providers.findIndex((group) => group.sources.length > 0); + const refreshLabel = t(refreshing ? 'hooks.refreshing' : 'hooks.refresh'); + const refresh = unsupportedReason ? undefined : ( + + + + ); + + return ( + +
    + {unsupportedReason ? ( +
    +
    + ) : loading && !snapshot ? ( +
    +
    + ) : errorCode && !snapshot ? ( +
    +
    + ) : ( +
    + {errorCode ? ( +
    + {t( + snapshot?.discoveryPending + ? 'hooks.pollInterrupted' + : 'hooks.staleAfterRefresh', + { code: errorCode }, + )} +
    + ) : null} + {refreshing ? ( +
    + {t('hooks.refreshing')} +
    + ) : null} + {snapshot?.discoveryPending && !errorCode ? ( +
    + {t('hooks.pending')} +
    + ) : null} + {snapshot + && !snapshot.discoveryPending + && snapshot.sources.length === 0 + && (snapshot.failedProviderIds ?? []).length === 0 + && snapshot.staleProviderIds.length === 0 ? ( +
    + {t('hooks.empty')} +
    + ) : null} + {providers.map(({ provider, sources }, index) => ( + = 0 ? firstProviderWithSources : 0)} + t={t} + /> + ))} + {catalogDiagnostics.length > 0 ? ( +
    +
    {t('hooks.diagnostics')}
    +
      + {visibleCatalogDiagnostics.map((diagnostic) => ( + + ))} +
    + {visibleCatalogDiagnostics.length < catalogDiagnostics.length ? ( + + ) : null} +
    + ) : null} +
    {t('hooks.readOnly')}
    +
    + )} +
    +
    + ); +}; + +export default ExternalHooksPanel; diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx index daef5a0fc9..7c3b06cbd1 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx @@ -6,6 +6,7 @@ import { createRoot, type Root } from 'react-dom/client'; import ExternalSourcesConfig from './ExternalSourcesConfig'; const getSnapshotMock = vi.hoisted(() => vi.fn()); +const getHookCatalogMock = vi.hoisted(() => vi.fn()); const setSourceEnabledMock = vi.hoisted(() => vi.fn()); const setSafeModeMock = vi.hoisted(() => vi.fn()); const setConflictChoiceMock = vi.hoisted(() => vi.fn()); @@ -80,6 +81,9 @@ vi.mock('@/infrastructure/api/service-api/ExternalSourcesAPI', () => ({ revealSourceLocation: revealSourceLocationMock, }, })); +vi.mock('@/infrastructure/api/service-api/ExternalHooksAPI', () => ({ + externalHooksAPI: { getCatalog: getHookCatalogMock }, +})); const snapshot = { hostCapabilities: { @@ -240,6 +244,16 @@ describe('ExternalSourcesConfig', () => { workspaceState.path = 'D:/workspace/project'; workspaceState.kind = 'normal'; peerState.deviceId = ''; + getHookCatalogMock.mockResolvedValue({ + schemaVersion: 1, + discoveryPending: false, + providers: [], + sources: [], + entries: [], + staleProviderIds: [], + failedProviderIds: [], + diagnostics: [], + }); getSnapshotMock.mockResolvedValue(snapshot); setSourceEnabledMock.mockResolvedValue(snapshot); setSafeModeMock.mockResolvedValue(snapshot); @@ -1461,13 +1475,17 @@ describe('ExternalSourcesConfig', () => { }); expect(container.textContent).toContain('agentChanges.unavailable'); expect(container.textContent).toContain('External Review'); - expect(container.querySelectorAll('[role="status"]')).toHaveLength(1); + expect(Array.from(container.querySelectorAll('[role="status"]')).filter( + (status) => status.textContent?.includes('agentChanges.unavailable'), + )).toHaveLength(1); await act(async () => { window.dispatchEvent(new Event('focus')); await Promise.resolve(); }); - expect(container.querySelectorAll('[role="status"]')).toHaveLength(1); + expect(Array.from(container.querySelectorAll('[role="status"]')).filter( + (status) => status.textContent?.includes('agentChanges.unavailable'), + )).toHaveLength(1); getSnapshotMock.mockResolvedValue({ ...activeSnapshot, diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx index 4ebff4efa8..f3ebc94e47 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx @@ -50,6 +50,7 @@ import { type ExternalSourcePresentationGroup, } from '../externalSourcePresentation'; import { externalSourceRequestScopeKey } from './externalSourceRequestScope'; +import ExternalHooksPanel from './ExternalHooksPanel'; import './ExternalSourcesConfig.scss'; const DISCOVERY_POLL_DELAYS_MS = [750, 1_500, 3_000, 5_000] as const; @@ -245,6 +246,7 @@ const ExternalSourcesConfig: React.FC = () => { const peerDeviceId = peerDevice?.peerMode.active ? peerDevice.peerMode.deviceId : undefined; const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); + const [hookRefreshEpoch, setHookRefreshEpoch] = useState(0); const [busyKey, setBusyKey] = useState(null); const [reviewingToolKey, setReviewingToolKey] = useState(null); const [reviewingAgentKey, setReviewingAgentKey] = useState(null); @@ -1111,7 +1113,10 @@ const ExternalSourcesConfig: React.FC = () => { size="small" aria-label={refreshing ? t('actions.refreshing') : t('actions.refresh')} disabled={refreshing || (!hostCapabilities.canRefresh && !error)} - onClick={() => void loadSnapshot(true, true)} + onClick={() => { + setHookRefreshEpoch((epoch) => epoch + 1); + void loadSnapshot(true, true); + }} >