diff --git a/docs/architecture/cli-product-line-design.md b/docs/architecture/cli-product-line-design.md index ffe3b65196..d3131a42c1 100644 --- a/docs/architecture/cli-product-line-design.md +++ b/docs/architecture/cli-product-line-design.md @@ -271,7 +271,8 @@ CLI-P1 应统一以下命令的文本和结构化只读视图: - Provider/认证来源的可用性、失效原因和登录/退出入口;密钥值只进入受控凭据提供方,不进入普通配置。 - 配置来源、被覆盖项、策略拒绝、未支持能力和降级原因。 - 外部 ACP 智能体与 OpenCode-compatible 插件必须作为两个独立能力展示。 -- CLI-P1 只允许显式应用已支持的非执行型配置候选;规则引用、Skill、MCP 启用和插件包仍按各自生命周期处理。 +- CLI-P1 的通用配置入口只允许显式应用已支持的非执行型候选;规则引用、Skill、MCP 启用和插件包仍按各自生命周期 + 处理。Claude Code/Codex 命令 Hook C0 是独立的精确审阅快照切片,不由通用配置入口取得执行资格。 ### 3.3 CLI-P2:扩展、定制与差异化 Agent 能力 @@ -485,41 +486,43 @@ Configuration 只能覆盖产品定义明确允许的默认值;用户插件只 外部进程、不 import 第三方 module、不读取凭据且不主动联网的 L1 字段可以按用户偏好自动应用或先询问。 Plugin/Tool、可执行 Skill/Command、MCP/LSP/Formatter、远程 Reference 等 L2/L3 内容在 OC-R2 完成归属模块保护 前只发现和展示;完成后仍须在首次启用或能力扩大时确认。它们无需先迁移; -显式导入用于用户希望把资产写入 BitFun 原生配置的场景。当前已落地的窄切片只有外部 MCP 快照:Desktop 与 -`bitfun mcp import` 可以预览 OpenCode / Claude Code 中语义等价的安全声明,只有显式 `--apply` 才原子写入现有 -BitFun MCP 配置;新条目保持 disabled,之后仍由既有 MCP 管理入口复核和启用。Codex 投影、凭据、header、env、cwd、 -通用导入记录和 undo 均未实现。该切片不表示通用 Canonical Config 导入已进入 CLI-P1;其他资产在 CLI-P0 仍截止到 -Dry-run,只有各自经过评审的 apply 切片才能写入: +显式导入用于用户希望取得 BitFun 独立管理快照的场景。当前只落地两个经过评审的窄切片:Desktop 与 +`bitfun mcp import` 可以预览 OpenCode / Claude Code 中语义等价的 MCP 安全声明,只有显式 `--apply` 才原子写入现有 +BitFun MCP 配置;`bitfun hooks` 和统一 `/hooks` 可预览 Claude Code / Codex 中受支持的同步 command Hook,并用精确 +计划指纹确认后复制到现有原生 Hook 层。两者都不写回来源文件,也不表示通用 Canonical Config 导入已进入 CLI-P1。 +MCP 的 Codex 投影、凭据、header、env、cwd、通用导入记录和 undo 均未实现;Hook 的 OpenCode、非 command 或依赖 +外部 Runtime 的 handler 仍只静态展示。其他资产在 CLI-P0 仍截止到 Dry-run,只有各自经过评审的 apply 切片才能写入: ```text 持续兼容:后台发现 -> 解析 -> 风险分级 -> L1 自动应用/先询问 | L2/L3 待确认 -> 同一次状态提交切换 MCP C0a:发现 -> 安全投影 -> 预览 | 显式 apply -> 原子写入 disabled 原生条目 -> 既有 MCP 管理 +Hook C0:脱敏发现 -> 精确命令预览 | 指纹确认 -> 原子发布本地快照 -> 既有 AgentHookEngine 其他显式导入:选择来源 -> 归一化 -> 冲突分析 -> Dry-run | 后续评审切片:用户选择 -> 原子写入 BitFun 层 ``` 交互式 CLI/TUI 以一条非阻塞摘要说明来源产品、全局/项目使用范围、资产数量、自动应用项和待确认项;详细内容进入 -统一来源与插件状态入口。MCP 快照入口固定为 `bitfun mcp import`,其他资产的命令名在有真实调用方时再固定。非交互命令只有在当前操作实际依赖待确认资产时才 +统一来源与插件状态入口。MCP 快照入口固定为 `bitfun mcp import`,Hook 快照入口固定为 `bitfun hooks`;其他资产的命令名在有真实调用方时再固定。非交互命令只有在当前操作实际依赖待确认资产时才 返回类型化 `action-required`;无关待办只进入结构化状态或 `stderr` 摘要,不等待不可见输入,也不自动批准。 当前只能静态预览的 custom tool 名称只显示“已发现,未执行”。 导入预览只使用四种用户可读结论:可直接使用、需要转换、会发生功能降级、输入无效。每项同时说明是原地 引用、写入 BitFun 配置、继续保持外部来源还是不支持;不得用“已映射”推导为已写入、已信任或已启用。 -兼容来源不写入 BitFun 层,也不双向修改原文件。以下分层导入记录与撤销语义是后续通用目标,不是 MCP C0a -已实现能力:项目级来源默认写入 BitFun 项目层,用户级来源默认写入用户层;用户可以在确认时选择更窄的目标层, +兼容来源不写入 BitFun 层,也不双向修改原文件。Hook C0 只保存用户/工作区范围的私有不可变快照,并提供启停、更新和 +删除,不实现字段级撤销。以下分层导入记录与撤销语义是后续通用目标,不是 MCP C0a 或 Hook C0 已实现能力:项目级来源默认写入 BitFun 项目层,用户级来源默认写入用户层;用户可以在确认时选择更窄的目标层, 但不能写入组织强制策略。导入记录保留来源产品、 来源范围、内容摘要和导入时间,并按字段保存目标层、导入前值及其版本/摘要和导入值。已导入字段以 BitFun 原生 配置为准,不再重复应用外部值;外部来源变化时提示重新导入并展示差异,不做双向写回。撤销只自动恢复当前值 仍等于导入值的字段;用户后续修改、来源变化或部分重新导入造成冲突时,逐字段选择“保留 BitFun / 重新导入 外部 / 手工处理”,不得整批覆盖。 -下表描述目标覆盖范围;当前 MCP C0a 仅支持上文列出的 OpenCode / Claude Code 安全投影,不能由本表推导为已实现。 +下表描述目标覆盖范围;当前能力仅限上文列出的 MCP C0a 与 Hook C0,不能由本表推导出其他资产已经实现。 | 来源 | 目标可导入 | 目标不导入 | |---|---|---| | OpenCode | 规则/instructions、Agent、Mode、Skill、References、Command、MCP、LSP、Formatter、模型、Theme、Keybind 和稳定配置进入兼容来源图;非执行资产可显式导入 | 凭据值双向复制、把 OpenCode 原始类型变成 BitFun 内部类型;Plugin/Tool 经来源确认后由独立 Runtime 加载,不通过配置导入执行 | -| Codex | `AGENTS.md` 原地引用;受支持的 MCP、稳定配置和 Skill 可选择原地引用或导入 | `auth.json` 等凭据、私有/未文档化字段、Codex App Server 状态 | -| Claude Code | `CLAUDE.md` 原地引用;受支持的 MCP、稳定设置和 Skill 可选择原地引用或导入 | OAuth/Token、插件执行、可写钩子、组织强制策略降级 | +| Codex | `AGENTS.md` 原地引用;受支持的 MCP、稳定配置和 Skill 可选择原地引用或导入;同步 command Hook 可经精确审阅复制为 BitFun 原生层 | `auth.json` 等凭据、私有/未文档化字段、Codex App Server 状态、依赖未观察会话/信任语义的 Hook | +| Claude Code | `CLAUDE.md` 原地引用;受支持的 MCP、稳定设置和 Skill 可选择原地引用或导入;同步 command Hook 可经精确审阅复制为 BitFun 原生层 | OAuth/Token、插件执行、非 command 或异步 Hook、managed Hook 例外、组织强制策略降级 | 规则文件优先复用项目已有文件,不复制出第二份内容。若不同生态规则冲突,导入报告必须展示目标文件、 优先级和冲突段,不能自动拼接。 @@ -568,8 +571,9 @@ OpenCode 来源解释,首次连接、策略限制和凭据缺失分别显示 | 执行 | 真实工具、稳定钩子、兼容 Client 和 TUI Plugin 经主机调用现有归属模块 | 插件不能直接写权限、工具结果、审计、会话或 Ratatui Frame | | 管理 | 查看、停用、恢复、更新和卸载;区分更新失败、暂时过期、明确删除和重新出现 | 安装成功不等于运行健康,服务入口和 TUI 入口分别管理 | -OpenCode 适配器必须读取真实外部来源;来源确认后才自动准备执行环境。显式导入只用于把非执行配置迁移为 -BitFun 原生配置,不能成为运行插件的前置条件。Codex/Claude 当前只进入配置资产兼容或导入,不进入插件执行阶段。 +OpenCode 适配器必须读取真实外部来源;来源确认后才自动准备执行环境。通用配置的显式导入只用于把非执行配置迁移为 +BitFun 原生配置,不能成为运行插件的前置条件。与其分离的 Hook C0 只把用户明确审阅的 Claude Code/Codex 命令 Hook +复制为现有 `AgentHookEngine` 的原生层;它不进入插件执行阶段,也不扩展到 OpenCode Hook 或通用 Hook Runtime。 CLI 只有在后端已经从脚本进程取得真实定义和执行函数、注册到现有 Tool Runtime 且当前 worker 健康时,才显示 “工具可用”。静态名称、准备中、制品不受支持、策略限制或执行进程不可用时,分别显示预览或具体原因,不能把 diff --git a/docs/architecture/extensions/external-ai-work-sources-design.md b/docs/architecture/extensions/external-ai-work-sources-design.md index ee321f59af..ad234bdd42 100644 --- a/docs/architecture/extensions/external-ai-work-sources-design.md +++ b/docs/architecture/extensions/external-ai-work-sources-design.md @@ -20,8 +20,9 @@ prompt-only Command。第二条端到端能力已让受支持的单文件 OpenCo 可执行来源选择规则。第五条端到端能力在不增加新的 Rust Runtime 进程的前提下接入 Claude Code 的 legacy Command、Subagent、 MCP 安全子集,以及 Codex Subagent、MCP 安全子集;三种生态使用同一个来源管理模块,并共享审批、冲突、刷新和故障隔离规则, 但各自在 sibling adapter 内保留原生来源与覆盖语义。完整 TypeScript/Bun、包依赖、package plugin 执行、 -Codex/Claude Code 运行时适配、primary agent 替换和外部 Subagent 续接仍属于后续阶段,不能因来源被识别就宣称已经可用。OpenCode、Claude Code 与 Codex 的本地 Hook 静态目录 -已作为独立只读切片接入;它只证明来源和声明能够安全展示,不证明 handler 已加载、获得权限或可以执行。 +Codex/Claude Code 运行时适配、primary agent 替换和外部 Subagent 续接仍属于后续阶段,不能因来源被识别就宣称已经可用。OpenCode、Claude Code 与 Codex 的本地 Hook 脱敏目录 +已作为独立只读切片接入;在此之上,Claude Code 与 Codex 的同步 command 子集可经精确命令审阅复制为 BitFun 管理的 +原生 Hook 层,仍由唯一 `AgentHookEngine` 执行。OpenCode handler、非 command/异步 handler 和未审阅声明仍不可执行。 独立的 MCP C0a 快照导入复用上述来源与现有 MCP 配置 owner:Desktop 和根 CLI 可预览 OpenCode / Claude Code 中语义等价的安全声明,并在用户显式确认后原子写入 disabled 原生条目。Codex 导入投影、凭据/header/env/cwd 迁移、通用导入记录、undo、Peer/Remote 写入均未实现;这不改变外部 MCP 持续兼容来源的运行路径。 @@ -155,28 +156,28 @@ GUI 和 TUI 都通过同一个 `SetSafeMode` 动作请求该变化,Peer Host | 方式 | 适用场景 | 来源变化后 | 写入边界 | |---|---|---|---| | 持续兼容来源(默认) | 继续使用外部应用维护的用户/项目内容 | 重新解析候选,按风险和用户策略自动切换或等待确认 | 不写 BitFun 配置,不写回外部文件。 | -| 显式导入(可选) | 用户希望取得一份由 BitFun 独立维护的快照 | 只提示外部来源有变化,用户选择是否重新导入 | 只写用户选定的 BitFun 配置层,支持字段级预览和撤销。 | +| 通用配置显式导入(可选) | 用户希望把受支持的非执行配置交给 BitFun 独立维护 | 只提示外部来源有变化,用户选择是否重新导入 | 只写用户选定的 BitFun 配置层,支持字段级预览和撤销。 | +| 命令 Hook 审阅导入(C0) | 用户希望让受支持的 Claude Code/Codex 命令 Hook 由现有原生 Hook owner 执行 | 只标记可更新,用户重新审阅并应用后才改变执行 | 只写产品私有托管快照;按来源整体更新、启停、移除或损坏重置,不提供字段级撤销。 | 显式导入完成后,已选字段由 BitFun 原生配置拥有,不再同时叠加外部值;未导入内容仍可继续作为兼容来源。 -插件、Tool 和 Hook 不通过配置复制获得执行资格。 +插件和 Tool 不通过配置复制获得执行资格。Claude Code/Codex command Hook 只通过下节的独立审阅快照路径进入既有 +原生 Hook owner,不复用通用配置导入或插件 Runtime。 -### 3.4 静态 Hook 目录 +### 3.4 Hook 脱敏目录与审阅导入 -Hook 首先以独立、只读的 `ExternalHookCatalogSnapshotV1` 展示,而不进入可执行来源管理模块。Desktop 设置页的 -“外部 AI 应用 → Hooks”和交互式 TUI 的 `/hooks_external`(别名 `/hooks-external`)消费同一份 Rust 快照; -`/help hooks_external`、`/hooks_external -h` 与 `/hooks_external --help` 提供说明,不增加快捷键、 -命名空间变体或生态专用命令。TUI 的 `/hooks` 属于 BitFun 自身会执行的 Hooks(见 -[agent-hooks](../../features/agent-hooks.zh-CN.md)),与本节的静态目录是两个视图,彼此在帮助文本中互相指向。 -`/hooks_external` 与其他内置命令采用同一套既有冲突策略:无冲突时使用普通命令名;发生 -同名冲突时由现有命令菜单展示来源限定项,静态 Hook 目录不增加另一套保留字或路由规则。 +Hook 首先以独立、只读的 `ExternalHookCatalogSnapshotV1` 脱敏展示。Desktop 的 **Agent Hooks** 设置页和交互式 TUI +统一 `/hooks` 同时展示 BitFun 原生层、外部来源和已导入快照;旧 `/hooks_external`、`/hooks-external` 仅保留为别名, +不再形成第二套产品心智或状态 owner。Claude Code/Codex 的受支持同步 command handler 只有在用户查看精确命令、 +依赖和跳过原因并确认计划指纹后,才复制到用户或工作区私有快照;导入、更新、启停和删除都不修改来源文件。 +OpenCode 与不受支持的 handler 仍停留在脱敏目录。 当前目录的来源与降级边界如下: -| 生态 | 当前静态来源 | 当前可见事实 | 明确不做 | +| 生态 | 当前来源 | 当前可见与可导入事实 | 明确不做 | |---|---|---|---| | 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 项目激活来源,这些未观察来源以覆盖诊断明确显示。 | +| Claude Code | 用户 `~/.claude/settings.json`,以及从项目根到当前工作区的 `.claude/settings.json`、`.claude/settings.local.json` | 目录仅展示 Hook 事件、matcher 与 handler 类型;有效 `disableAllHooks` 按所选来源层级解释。受支持的同步 command、matcher、timeout、status 和安全文件依赖可在私有准备阶段进入精确审阅计划 | 不导入 managed Hook 例外、`http`/`mcp_tool`/`prompt`/`agent`、异步或未知字段;任一参与层无效或超限时不猜测激活状态。公开目录不传输 handler 正文。 | +| Codex | 用户与按持久 `project_root_markers` 有界的项目祖先 `hooks.json`、`config.toml`;linked worktree 映射到主 checkout 对应目录 | 目录展示固定 schema 的事件与 handler 类型;受支持的同步 command、Windows override、timeout、status 和安全文件依赖可进入精确审阅计划 | 不猜测插件、托管层、会话注入、state/feature 合并或 trust-gated 项目激活;依赖这些未观察语义的声明不导入。 | 只有语义完全一致的 `PreToolUse`/`PostToolUse` 和 OpenCode `tool.execute.before`/`tool.execute.after` 分别映射到 BitFun 已有 `ToolBefore`/`ToolAfter` 契约。其他原生事件仍可见,但标为 `native_only`;静态分析不能安全确定的注册 @@ -194,7 +195,8 @@ provider 失败事实,避免在空目录界面中伪装成“成功但没有 条目和诊断设置输出上限,避免大型目录一次挂载或打印数千项。Git/worktree 服务只提供当前 checkout 边界与主 checkout 身份;adapter 在最多 32 层的边界内解释各生态祖先规则,无法确认 Git 边界时只读取当前工作区,不向任意父目录扩散。当前只允许 本机执行域:Remote workspace 和 Peer Device Mode 显示明确不支持,绝不 -回退读取控制端本地同名配置。Server、Mobile、SDK、ACP、Host 和任何 Hook Runtime 都不属于该切片。 +回退读取控制端本地同名配置。Server、Mobile、SDK、ACP、Peer/Remote Host 不提供该管理切片;本地导入只复用既有 +原生 Hook Runtime,不新增外部 Runtime。 ## 4. 来源、资产与加载策略 @@ -221,7 +223,7 @@ provider 失败事实,避免在空目录界面中伪装成“成功但没有 | L0 仅清单 | 尚未支持的字段、静态插件/工具名称、来源元数据 | 自动发现和展示,绝不宣称已经应用或可执行。 | | L1 被动声明 | 本地 Rules、Instructions、纯声明配置、Skill 的说明和索引 | 校验后默认自动应用;显示一次可撤销摘要。不得启动进程、读取凭据或主动联网。 | | L2 受归属模块保护的外部能力 | 可执行 Skill/Command、远程 Reference、MCP、LSP、Formatter、Provider 连接 | 发现后进入“需确认”;由真实归属模块展示命令、网络、凭据和使用范围后启用。 | -| L3 任意第三方代码 | JS/TS Tool、服务插件、Hook、TUI 插件入口、动态 import | 发现但不 import;首次按来源、插件身份和执行域启用时说明 OS 用户、工作目录,以及文件、网络和进程权限;不能承诺 import 前已知全部动态贡献。 | +| L3 任意第三方代码 | JS/TS Tool、服务插件、动态 Hook/TUI 入口、动态 import | 默认发现但不 import;只有能在执行前完整枚举命令与依赖、并由既有归属模块承担执行的窄切片可经独立设计和精确审阅启用。不能承诺动态 import 前已知全部贡献。 | OpenCode Subagent 属于 L2:adapter 只读取声明,不执行外部代码;激活仍需确认实际模型、工具、执行域和来源关系。 仅 description 等 catalog 文案变化不会扩大运行权限,因此不重复询问;prompt 行为、来源、模型或工具变化必须重新确认。 @@ -267,7 +269,7 @@ OpenCode Subagent 属于 L2:adapter 只读取声明,不执行外部代码; | MCP | 用户/显式目录/项目配置的安全子集 | user/project/local 原生层的安全子集 | 用户与项目 `config.toml` 原生层的安全子集 | 支持可表达的 stdio 与 HTTPS Streamable HTTP;发现不启动 Server,首次激活继续经 BitFun MCP 审批。OAuth、remote executor、per-tool policy 等不完整语义明确降级。 | | Standalone Tool | 已有单文件 JavaScript 子集 | 无稳定的 runtime-free standalone Tool 来源 | 无稳定的 runtime-free standalone Tool 来源 | TypeScript、package/plugin Tool 与动态工具注册依赖独立 Plugin Host,不在声明式 adapter 中猜测。 | | Skill | 由现有 Skill 加载模块发现 `.opencode` 等标准根 | 由现有 Skill 加载模块发现 `.claude` 标准根 | 由现有 Skill 加载模块发现 `.codex`、`.agents` 标准根 | Skill 的加载、覆盖、模式开关与执行仍由同一个 Skill 模块负责,不复制进外部来源管理模块。 | -| Hook | 静态目录 | 静态目录 | 静态目录 | 只展示脱敏声明与等价 Hook point 覆盖;不 import、不执行 handler。 | +| Hook | 静态目录 | 脱敏目录;同步 command 子集可审阅导入 | 脱敏目录;同步 command 子集可审阅导入 | 仅复制到私有原生快照并由 `AgentHookEngine` 执行;OpenCode、非 command、异步、未知或依赖未观察激活语义的 handler 不导入。 | 生态原生语义由各 adapter 以契约测试固定,不抽象成全局优先级: @@ -386,7 +388,7 @@ flowchart LR | 外部来源目录 | 聚合来源身份、使用范围、资产清单、用户处理偏好和可读状态 | 解释所有生态格式、保存凭据、授予脚本权限或管理 worker。 | | 生态发现与解析适配器 | 发现本生态标准来源,保留真实优先级、格式、参数展开和诊断,并通过能力专属 provider 输出 | 写 BitFun 配置、依赖兄弟生态 adapter、执行其他生态语义或创建跨生态最低公分母。 | | 能力专属 provider 契约 | 用来源限定身份交付 Command、Tool、Subagent 等类型明确的定义与调用/展开结果 | 携带任意数据的通用资产对象,或让一种能力的新增字段污染其他能力。 | -| 静态 Hook provider 与目录协调器 | 聚合 OpenCode、Claude Code、Codex 的脱敏声明;隔离 provider 失败、保留最后有效结果,并向 Desktop/TUI 生成只读视图 | import handler、选择脚本运行时、授予执行权限、复用可执行来源审批 DTO,或把静态映射宣称为运行时兼容。 | +| Hook provider 与目录协调器 | 聚合三个生态的脱敏声明并隔离 provider 失败;对 Claude Code/Codex 所选来源执行版本守卫的私有 command 准备 | 执行 handler、选择脚本运行时、授予 OpenCode 执行权限,或把未导入的静态映射宣称为运行时兼容。 | | 文件观察服务 | 提供可订阅、去抖的文件变化事实 | 解释生态路径、决定优先级、提交业务状态。 | | 本地 JSON 存储服务 | 提供跨进程锁、锁内读改写和同卷原子替换;替换失败时保留旧文件 | 定义外部来源偏好 schema、冲突策略或生态语义。 | | `ExternalSourceControlPlane` | 四类来源分别刷新;同一 provider 同一时间只扫描一次;超时只影响该 provider;旧结果不能覆盖新刷新;确认最新结果后,再通知对应能力模块切换 | 按生态 ID 分支业务行为、把四类数据合并为通用资产、解析生态文件、直接提交配置、工具、权限或界面状态。 | @@ -394,7 +396,7 @@ flowchart LR | 界面状态 | 按使用范围、工作区或用户目录关系统一生成安全来源位置,清理诊断文本中的已知绝对路径,并按 `Source / Command / Tool / Subagent` 资源类型路由诊断 | 让 GUI/TUI 解析 provider 诊断码前缀、识别 `.opencode`、`.claude` 等私有目录结构,或接收原始用户/工作区路径。 | | 冲突解析 | 对独立 provider 或产品本地可执行能力的同名候选建立版本敏感内容摘要;未选择时不激活,选择后只在内容摘要不变时复用。现有 Skill 固定根顺序由 Skill 归属模块独立维护 | 用 adapter 优先级静默覆盖另一生态或本地可执行能力,或把选择写回外部文件。 | | 激活策略与能力归属模块 | 根据风险、用户选择、组织上限和执行位置决定自动应用、等待确认或限制 | 修改生态加载顺序或把策略拒绝伪装成解析失败。 | -| Runtime Configuration Service | 应用兼容配置视图,执行显式导入、冲突预览、原子写入和撤销 | 读取凭据值或加载插件代码。 | +| Runtime Configuration Service | 应用兼容配置视图,执行通用配置的显式导入、冲突预览、原子写入和撤销 | 读取凭据值、加载插件代码或拥有命令 Hook C0 的私有快照。 | | `PluginRuntimeClient` | 当前路由调用并管理期限、同一插件串行调用、重复请求结果、响应校验与故障诊断;目标再增加队列上限、取消后的结果失效和旧连接结果拒绝 | 执行第三方代码、成为物理进程或插件生命周期归属模块,或决定来源优先级和最终业务状态。 | | `ScriptToolRuntime` 与 Plugin Host | services 实现管理物理进程事实;Plugin Host 子进程加载并执行已批准的 JS/TS 插件 | 把 Rust 侧实现命名为 Host、把工作区当作默认进程边界,或为每个插件建立强隔离。详细生命周期见[插件运行时设计](plugin-runtime-design.md)。 | | 产品入口 | 展示统一状态并发起用户操作 | 直接扫描目录、同步安装依赖或依赖生态原始对象。 | @@ -532,16 +534,18 @@ Command;明确缺失且未被标记失败的 Command 是稳定删除。产品 上下文的外部 runtime id;外部服务器发起的 roots、sampling 和 elicitation 请求也一律拒绝,防止跨工作区读取或借用 BitFun 宿主能力。后续若接入这些能力,必须先补独立契约、工作区路由与权限交互,不能复用全局连接绕过当前边界。 -独立的静态 Hook 目录切片不依赖上述运行时阶段: +独立 Hook 切片不依赖 Plugin Runtime 阶段: 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 或运行时视图。 + `assembly/external-sources` 负责 provider 聚合、last-valid 和受版本守卫的准备入口,`assembly/core` 负责本地产品装配、 + 计划/应用 fencing、私有快照协调和向既有原生 Hook owner 投影,不解析生态格式或执行命令。 +2. Desktop/Web UI、根 CLI 与交互式 TUI 只消费共享目录、计划和管理快照;不额外提供 Server、Remote、Peer、Mobile + 或 SDK 管理面。 3. 验收覆盖脱敏序列化、部分失败、首次失败与空目录区分、非法/过大输入、有界枚举、provider 身份冲突、刷新竞态、stale 结果、保留命令、 `/help hooks`、GUI 空/错/刷新/不支持状态;Host 返回未知 v1 枚举或可执行字段时明确拒绝。 -4. 后续覆盖率、版本差异和冲突分析在此目录之上增加只读派生事实;Hook 执行 Host、权限、顺序、取消与故障恢复必须 - 另立运行时切片,不能通过扩展本目录 DTO 偷渡执行语义。 +4. Claude Code/Codex 同步 command 导入通过私有准备、精确审阅、generation fencing 和不可变本地快照进入既有 + `AgentHookEngine`;托管快照在发布、重新打开及引擎重建读取时校验同一份有界内容摘要,校验失败即停止加载。 + OpenCode Hook 执行、外部 Runtime、持续监听及更复杂 handler 必须另立运行时切片,不能通过扩展目录 DTO 偷渡执行语义。 验收至少覆盖: @@ -566,7 +570,8 @@ Command;明确缺失且未被标记失败的 Command 是稳定删除。产品 - GUI/TUI 的外部 Agent 冲突选择在同一上下文中展示将被原子批准的模型、工具、执行域、安全来源、兼容影响和恢复动作; 同工作区决策串行化,成功后读取权威快照,不以较旧整表覆盖无关的 Command/Tool 新状态。 - 冲突偏好按执行域与命令族只保留当前内容摘要,并以去重候选身份标记曾发生冲突;连续内容更新不会按历史内容摘要线性膨胀。 -- 显式导入的字段级预览、冲突、撤销和凭据脱敏可验证。 +- 通用配置显式导入的字段级预览、冲突、撤销和凭据脱敏可验证;命令 Hook C0 则验证脱敏目录、精确审阅、 + 整体更新/启停/移除、并发 stale 拒绝和损坏后显式重置,不借用字段级撤销语义。 - 当前只支持静态预览的资产不会被产品文案误报为已应用或可执行;支持子集与完整 OpenCode 兼容不会混写。 具体 OpenCode 能力范围和近期顺序分别见 diff --git a/docs/architecture/product-architecture.md b/docs/architecture/product-architecture.md index ac4d77bdd1..75996f2086 100644 --- a/docs/architecture/product-architecture.md +++ b/docs/architecture/product-architecture.md @@ -274,9 +274,9 @@ Plugin Runtime P0 只验证了 BitFun 专用插件目录中的来源校验、工 可把受支持的单文件 `.js` 经确认后接入现有 Tool Runtime;Subagent 可把全局/项目声明的安全子集经确认和同名冲突 选择后接入现有 Task/Subagent 归属模块;fresh single-run 调用持续使用启动时选定的版本。MCP 可把受支持的用户/项目 配置经确认和同名冲突选择后交给现有 MCP owner 运行。四类贡献对象互不复用,主体逻辑不按生态分支。当前仍不表示 -package plugin、Hook、primary agent、外部 agent 续接、SSH Remote 工作区来源发现或完整 -配置兼容已经可用。独立的静态 Hook 目录可以发现并脱敏展示 OpenCode、Claude Code 与 Codex 的本地声明,但不加载 -handler、不授予权限,也不改变这里对 Hook Runtime“尚不可用”的判断。 +package plugin、OpenCode/通用动态 Hook Runtime、primary agent、外部 agent 续接、SSH Remote 工作区来源发现或完整 +配置兼容已经可用。独立目录可以发现并脱敏展示 OpenCode、Claude Code 与 Codex 的本地 Hook 声明;其中只有明确审阅的 +Claude Code/Codex 命令子集可复制为既有 `AgentHookEngine` 的原生层,OpenCode 和其余声明仍不加载 handler 或授予权限。 目标路线不要求 OpenCode 插件作者维护 `bitfun.plugin.json` 或复制到 `.bitfun/plugins`。BitFun 直接发现用户和 项目的 OpenCode 配置、插件目录、工具目录和软件包来源;低风险内容按用户偏好自动应用或先询问,可执行来源在 @@ -406,7 +406,7 @@ flowchart LR | 当前入口 | 已有能力 | 明确边界 | |---|---|---| | Desktop | 使用 `product-full`;显示外部来源、审批、冲突、诊断和 Host 能力 | 可执行能力在事实所在 Host 运行;Safe Mode 只阻止新调用,不改来源、不取消正在运行的调用 | -| CLI / TUI | 使用 `product-full`;提供 `/extensions`、`/hooks_external`、`/tools` 和 `/agents` | 不解析生态文件,不启动第二套 Agent Runtime;远程能力未接入时不回退本机 | +| CLI / TUI | 使用 `product-full`;提供 `/extensions`、统一 `/hooks`(旧 `/hooks_external` 为别名)、`/tools` 和 `/agents`;Claude Code/Codex 命令 Hook 可经显式审阅复制为原生层 | 生态解析仍在适配器,不启动第二套 Agent Runtime;OpenCode Hook 仍只静态发现;远程能力未接入时不回退本机 | | ACP | 使用 `DeliveryProfile::Acp` 和 Runtime Parts | load 成功后才发布活动状态;close 排空后再卸载;完整历史和配置仍由 Core/ACP 管理 | | Peer / Server | Server 提供 control/catalog;Peer Host 执行真实工作区操作;当前 HTTP Server 不装配 Agent Runtime | 控制端不替远端发现或执行;旧 Host 明确降级,SSH Remote 未接入时返回不支持;只读 Server 不声明 Runtime ownership | | Web / Mobile Web | 依赖现有后端入口 | 不持有插件执行单元,也不能据空 profile 宣称独立能力 | diff --git a/docs/features/agent-hooks.md b/docs/features/agent-hooks.md index 75dc0b192a..4826942f9e 100644 --- a/docs/features/agent-hooks.md +++ b/docs/features/agent-hooks.md @@ -80,6 +80,68 @@ you trust, and re-check the file after pulling. Codex's `[features] hooks = false` has no BitFun equivalent — use `app.hooks.enabled` instead. +## Importing Claude Code and Codex command hooks + +BitFun can take a reviewed local snapshot of compatible `type: "command"` +hooks discovered from Claude Code or Codex. This is an explicit copy, not a +live mount of another product's configuration: + +1. Open **Settings → Agent Hooks** or run `/hooks` in the TUI. Use + `bitfun hooks list` from the root CLI for a scriptable view. +2. Choose a source and review every effective command, Windows override, + timeout, copied or external dependency, skipped item, and the plan + fingerprint. +3. Confirm that exact plan. If the source changed after review, BitFun writes + nothing and returns a refreshed plan for another confirmation. + +User sources are copied to BitFun's user-managed data. Project sources are +copied to workspace-isolated data under BitFun's project runtime area. Safe +relative script dependencies beneath a source's `.claude/hooks` or +`.codex/hooks` directory are copied into the immutable snapshot. Absolute +dependencies remain external and are called out during review; moving or +changing one can therefore change behavior without updating the snapshot. +Dynamic paths, globs, escaping paths, links, unreadable files, and files beyond +the fixed import limits are skipped rather than followed implicitly. + +An imported source can be enabled, disabled, updated, or removed independently. +Removing it deletes only BitFun's managed copy; it never edits Claude Code or +Codex files. Updates always require another exact-command review. Imported +layers run in this fixed order: + +1. manual user `hooks.json`; +2. enabled user imports, ordered by stable import id; +3. manual project `hooks.json`, when project hooks are enabled; +4. enabled project imports, ordered by stable import id. + +Import, update, enable, disable, and remove take effect on the next matching +Hook event; an already running Hook finishes against the snapshot it started +with. BitFun does not re-import on startup, poll, or watch Claude Code/Codex +files. Use **Refresh** or `/hooks refresh` to check for source changes, then +review an update explicitly. The management and execution paths are local-only; +remote workspaces return unsupported instead of running local commands against +a remote path. + +OpenCode plugin Hooks are intentionally excluded. Their JavaScript callbacks +need the OpenCode plugin execution domain; the current OpenCode Hook catalog is +still discovery/static preview and is not executable. + +Root CLI equivalents are: + +```text +bitfun hooks list [--refresh] [--format text|json] +bitfun hooks import --source [--confirm ] +bitfun hooks update [--confirm ] +bitfun hooks enable +bitfun hooks disable +bitfun hooks remove --confirm +bitfun hooks reset --confirm +``` + +Import and update are preview-only without the matching fingerprint. TUI uses +the same backend and keeps `/hooks_external` and `/hooks-external` as aliases +for the unified `/hooks` management view. `reset` is available only as explicit +recovery for a corrupt BitFun-managed index and never changes source files. + ## Quick start Create `/config/hooks.json`: @@ -210,9 +272,8 @@ log level. ## Related -- CLI `/hooks` shows the hooks described here — which files they came from, - which layers are active, and what each matcher group would run. It reports - the configuration only; edit `hooks.json` to change it. -- CLI `/hooks_external` inspects hooks configured for *other* AI applications - (Claude Code, Codex, OpenCode). That view is read-only and never executes - anything; the hooks described here are BitFun's own and do execute. +- CLI `/hooks` shows manual and imported layers, discovers supported external + sources asynchronously, and owns the import management actions described + above. Edit `hooks.json` directly only for manual BitFun layers. +- `/hooks_external` and `/hooks-external` are compatibility aliases for the + same view; they do not create a second import or execution path. diff --git a/docs/features/agent-hooks.zh-CN.md b/docs/features/agent-hooks.zh-CN.md index 3fd909c0b8..985294739a 100644 --- a/docs/features/agent-hooks.zh-CN.md +++ b/docs/features/agent-hooks.zh-CN.md @@ -69,6 +69,58 @@ Codex 读 `~/.codex/hooks.json`,BitFun 改为读自己的配置目录。文件 Codex 的 `[features] hooks = false` 在 BitFun 没有对应项,请使用 `app.hooks.enabled`。 +## 导入 Claude Code 与 Codex 命令 Hooks + +BitFun 可以把 Claude Code 或 Codex 中兼容的 `type: "command"` Hook 保存为一份 +经审阅的本地快照。这是一次显式复制,不是实时挂载其他产品的配置: + +1. 打开**设置 → Agent Hooks**,或在 TUI 中运行 `/hooks`;脚本化查看可使用 + `bitfun hooks list`。 +2. 选择来源,并审阅每条实际命令、Windows 覆盖命令、超时、复制或外部依赖、 + 跳过项以及计划指纹。 +3. 确认这份确切计划。若来源在审阅后发生变化,BitFun 不会写入,而是返回一份 + 刷新后的计划,要求再次确认。 + +用户来源复制到 BitFun 的用户托管数据;项目来源复制到 BitFun 项目运行区内按工作区 +隔离的数据。来源 `.claude/hooks` 或 `.codex/hooks` 目录下可安全解析的相对脚本依赖 +会被复制到不可变快照。绝对路径依赖仍保留为外部依赖,并在审阅时明确显示;移动或 +修改它可能在不更新快照的情况下改变行为。动态路径、通配符、路径逃逸、链接、不可读 +文件以及超出固定导入上限的文件会被跳过,不会被隐式跟随。 + +每个已导入来源都可单独启用、停用、更新或移除。移除只删除 BitFun 的托管副本,绝不 +修改 Claude Code 或 Codex 文件;更新始终要求重新审阅实际命令。各层按以下固定顺序 +执行: + +1. 手工用户级 `hooks.json`; +2. 已启用的用户级导入,按稳定导入 ID 排序; +3. 手工项目级 `hooks.json`(项目 Hooks 已开启时); +4. 已启用的项目级导入,按稳定导入 ID 排序。 + +导入、更新、启用、停用和移除会在下一个匹配的 Hook 事件生效;已经开始运行的 Hook +仍使用启动时捕获的快照完成。BitFun 不会在启动时重新导入,也不会轮询或监听 Claude +Code/Codex 文件。请使用**刷新**或 `/hooks refresh` 检查来源变化,再显式审阅更新。 +管理与执行均只支持本地工作区;远程工作区会明确返回不支持,不会用本地命令处理远程 +路径。 + +OpenCode 插件 Hooks 明确不在本次范围内。其 JavaScript 回调依赖 OpenCode 插件执行域; +当前 OpenCode Hook 目录仍只用于发现和静态预览,不能执行。 + +根 CLI 对应命令如下: + +```text +bitfun hooks list [--refresh] [--format text|json] +bitfun hooks import --source [--confirm ] +bitfun hooks update [--confirm ] +bitfun hooks enable +bitfun hooks disable +bitfun hooks remove --confirm +bitfun hooks reset --confirm +``` + +未提供匹配指纹时,导入和更新只做预览。TUI 复用同一后端,并保留 +`/hooks_external`、`/hooks-external` 作为统一 `/hooks` 管理视图的兼容别名。 +`reset` 只用于显式恢复损坏的 BitFun 托管索引,绝不会修改来源文件。 + ## 快速开始 创建 `<用户配置目录>/config/hooks.json`: @@ -194,8 +246,7 @@ Hook 是以你的用户权限运行的任意代码,且每次对应事件触发 ## 相关 -- CLI 的 `/hooks` 展示的就是本文描述的 Hooks:来自哪些文件、哪些层级生效、每个 - 匹配组会运行什么。它只报告配置,修改请直接编辑 `hooks.json`。 -- CLI 的 `/hooks_external` 用于查看*其他* AI 应用(Claude Code、Codex、OpenCode) - 配置的 Hooks。该视图只读,不会执行任何内容;本文描述的是 BitFun 自身的 Hooks, - 它们会真正执行。 +- CLI 的 `/hooks` 同时展示手工与导入层,异步发现受支持的外部来源,并提供上述导入 + 管理操作。只有手工 BitFun 层需要直接编辑 `hooks.json`。 +- `/hooks_external` 与 `/hooks-external` 是同一视图的兼容别名,不会形成第二套导入或 + 执行路径。 diff --git a/scripts/core-boundaries/rules/feature-rules.mjs b/scripts/core-boundaries/rules/feature-rules.mjs index 1ef4a602d9..37b130e62d 100644 --- a/scripts/core-boundaries/rules/feature-rules.mjs +++ b/scripts/core-boundaries/rules/feature-rules.mjs @@ -69,13 +69,13 @@ export const optionalDependencyFeatureOwnerRules = [ depName: 'base64', ownerFeatures: ['mcp', 'miniapp-runtime', 'remote-connect', 'remote-ssh-concrete', 'speech'], }, - { depName: 'bitfun-agent-runtime', ownerFeatures: ['deep-research'] }, + { depName: 'bitfun-agent-runtime', ownerFeatures: ['deep-research', 'hook-import'] }, { depName: 'bitfun-core-types', ownerFeatures: ['speech'] }, - { depName: 'bitfun-product-domains', ownerFeatures: ['canvas-runtime', 'function-agents', 'miniapp-runtime', 'plugin-source'] }, + { depName: 'bitfun-product-domains', ownerFeatures: ['canvas-runtime', 'function-agents', 'hook-import', 'miniapp-runtime', 'plugin-source'] }, { depName: 'bitfun-runtime-ports', ownerFeatures: ['remote-connect', 'remote-ssh', 'remote-ssh-concrete', 'script-tool-runtime'] }, { depName: 'bitfun-services-core', - ownerFeatures: ['browser-control', 'git', 'mcp', 'miniapp-runtime', 'process-tree', 'remote-connect', 'remote-ssh-concrete', 'review-platform', 'workspace-search'], + ownerFeatures: ['browser-control', 'git', 'hook-import', 'mcp', 'miniapp-runtime', 'process-tree', 'remote-connect', 'remote-ssh-concrete', 'review-platform', 'workspace-search'], }, { depName: 'bzip2', ownerFeatures: ['speech'] }, { depName: 'chrono', ownerFeatures: ['debug-log', 'git', 'remote-connect', 'remote-ssh-concrete', 'review-platform', 'speech'] }, @@ -85,7 +85,7 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'futures', ownerFeatures: ['mcp', 'remote-connect', 'review-platform'] }, { depName: 'futures-util', ownerFeatures: ['speech'] }, { depName: 'git2', ownerFeatures: ['git'] }, - { depName: 'hex', ownerFeatures: ['mcp', 'plugin-source', 'remote-connect'] }, + { depName: 'hex', ownerFeatures: ['hook-import', 'mcp', 'plugin-source', 'remote-connect'] }, { depName: 'hostname', ownerFeatures: ['remote-connect'] }, { depName: 'image', ownerFeatures: ['remote-connect'] }, { depName: 'local-ip-address', ownerFeatures: ['remote-connect'] }, @@ -107,18 +107,18 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'rustls', ownerFeatures: ['remote-connect'] }, { depName: 'rustls-native-certs', ownerFeatures: ['remote-connect'] }, { depName: 'schannel', ownerFeatures: ['remote-connect'] }, - { depName: 'sha2', ownerFeatures: ['canvas-runtime', 'mcp', 'plugin-source', 'remote-connect', 'remote-ssh', 'review-platform', 'speech'] }, + { depName: 'sha2', ownerFeatures: ['canvas-runtime', 'hook-import', 'mcp', 'plugin-source', 'remote-connect', 'remote-ssh', 'review-platform', 'speech'] }, { depName: 'sherpa-onnx', ownerFeatures: ['speech'] }, { depName: 'shellexpand', ownerFeatures: ['remote-ssh-concrete'] }, { depName: 'sse-stream', ownerFeatures: ['mcp'] }, { depName: 'ssh_config', ownerFeatures: ['remote-ssh-concrete', 'ssh_config'] }, { depName: 'terminal-core', ownerFeatures: ['remote-ssh', 'remote-ssh-concrete'] }, { depName: 'tar', ownerFeatures: ['speech'] }, - { depName: 'thiserror', ownerFeatures: ['browser-control', 'git', 'plugin-source', 'remote-ssh', 'remote-ssh-concrete', 'review-platform', 'speech', 'web-tools', 'workspace-search'] }, + { depName: 'thiserror', ownerFeatures: ['browser-control', 'git', 'hook-import', 'plugin-source', 'remote-ssh', 'remote-ssh-concrete', 'review-platform', 'speech', 'web-tools', 'workspace-search'] }, { depName: 'tokio-tungstenite', ownerFeatures: ['remote-connect'] }, { depName: 'tokio-util', ownerFeatures: ['remote-ssh', 'speech'] }, { depName: 'urlencoding', ownerFeatures: ['canvas-runtime', 'remote-connect', 'review-platform'] }, - { depName: 'uuid', ownerFeatures: ['canvas-runtime', 'debug-log', 'miniapp-runtime', 'plugin-source', 'remote-connect', 'remote-ssh-concrete', 'speech'] }, + { depName: 'uuid', ownerFeatures: ['canvas-runtime', 'debug-log', 'hook-import', 'miniapp-runtime', 'plugin-source', 'remote-connect', 'remote-ssh-concrete', 'speech'] }, { depName: 'which', ownerFeatures: ['miniapp-runtime', 'remote-connect', 'script-tool-runtime', 'workspace-search'] }, { depName: 'windows', ownerFeatures: ['plugin-source', 'review-platform'] }, { depName: 'x25519-dalek', ownerFeatures: ['remote-connect'] }, @@ -217,6 +217,7 @@ export const ownerCrateFeatureAssemblyRules = [ 'file-watch', 'function-agents', 'git', + 'hook-import', 'miniapp-runtime', 'mcp', 'plugin-source', diff --git a/scripts/core-boundaries/rules/source/public-api-rules.mjs b/scripts/core-boundaries/rules/source/public-api-rules.mjs index 673f8455ba..c09927278f 100644 --- a/scripts/core-boundaries/rules/source/public-api-rules.mjs +++ b/scripts/core-boundaries/rules/source/public-api-rules.mjs @@ -358,6 +358,22 @@ export const staticHookSupportPublicApiEntries = [ 'static-hook-support parser owner', 'OpenCode, Claude Code, and Codex static Hook source adapters', )).concat([ + 'PreparedStaticHookCommand', + 'StaticHookAssetError', + 'importable_hook_matcher', + 'required_hook_string', + 'optional_hook_string', + 'optional_positive_hook_u64', + 'prepare_static_hook_command', + 'StaticHookVisitSummary', + 'StaticHookHandlerRef', + 'visit_hook_document', + 'static_hook_handler_fact', +].map((symbol) => staticHookAdapterEntry( + symbol, + 'static-hook-support command import owner', + 'Claude Code and Codex command Hook adapters', +))).concat([ 'BoundedFileResolveError', 'resolve_bounded_regular_file', 'redacted_executable_preview', diff --git a/src/apps/cli/src/actions.rs b/src/apps/cli/src/actions.rs index fd4fb8246b..95834a046e 100644 --- a/src/apps/cli/src/actions.rs +++ b/src/apps/cli/src/actions.rs @@ -446,7 +446,7 @@ static ACTION_SPECS: &[ActionSpec] = &[ id: "hooks", name: "Hooks", aliases: &["/hooks"], - description: "View BitFun Hooks for agent lifecycle events", + description: "Review and manage native and imported Hooks", contexts: CHAT, availability: ActionAvailability::Always, handler: ActionHandler::NativeHooks, @@ -459,9 +459,9 @@ static ACTION_SPECS: &[ActionSpec] = &[ }, ActionSpec { id: "hooks_external", - name: "External Hooks", + name: "Hooks (compatibility alias)", aliases: &["/hooks_external", "/hooks-external"], - description: "Inspect external AI application Hooks", + description: "Open the unified Hook management view", contexts: CHAT, availability: ActionAvailability::Always, handler: ActionHandler::ExternalHooks, diff --git a/src/apps/cli/src/hook_import.rs b/src/apps/cli/src/hook_import.rs new file mode 100644 index 0000000000..9eb0d9ee78 --- /dev/null +++ b/src/apps/cli/src/hook_import.rs @@ -0,0 +1,472 @@ +use anyhow::{anyhow, Result}; +use bitfun_product_domains::external_hook_import::{ + ExternalHookImportApplyRequestV1, ExternalHookImportDependencyV1, + ExternalHookImportMutationRequestV1, ExternalHookImportMutationV1, ExternalHookImportPlanV1, + ExternalHookImportSnapshotV1, EXTERNAL_HOOK_IMPORT_SCHEMA_V1, +}; +use bitfun_product_domains::external_sources::{ + ExternalSourceOperationError, ExternalSourceScope, SourceKey, +}; +use clap::{Subcommand, ValueEnum}; +use serde::Serialize; +use std::path::PathBuf; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub(crate) enum HookImportOutputFormat { + Text, + Json, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub(crate) enum HookImportResetScope { + User, + Project, +} + +#[derive(Subcommand)] +pub(crate) enum HookAction { + /// List available and imported Hook sources + List { + /// Re-read source files and check imported sources for updates + #[arg(long)] + refresh: bool, + /// Output format for automation + #[arg(long, value_enum, default_value_t = HookImportOutputFormat::Text)] + format: HookImportOutputFormat, + }, + /// Preview or confirm importing one Hook source + Import { + /// Stable source key shown by `bitfun hooks list` + #[arg(long)] + source: String, + /// Confirm the exact plan fingerprint shown by the preview + #[arg(long, value_name = "PLAN_FINGERPRINT")] + confirm: Option, + /// Output format for automation + #[arg(long, value_enum, default_value_t = HookImportOutputFormat::Text)] + format: HookImportOutputFormat, + }, + /// Preview or confirm updating one imported Hook source + Update { + import_id: String, + /// Confirm the exact plan fingerprint shown by the preview + #[arg(long, value_name = "PLAN_FINGERPRINT")] + confirm: Option, + /// Output format for automation + #[arg(long, value_enum, default_value_t = HookImportOutputFormat::Text)] + format: HookImportOutputFormat, + }, + /// Enable one imported Hook source + Enable { import_id: String }, + /// Disable one imported Hook source without deleting it + Disable { import_id: String }, + /// Remove only BitFun's managed copy of one imported Hook source + Remove { + import_id: String, + /// Confirm removal of the BitFun-managed copy + #[arg(long, required = true)] + confirm: bool, + }, + /// Reset a corrupt BitFun-managed Hook index without changing source files + Reset { + #[arg(value_enum)] + scope: HookImportResetScope, + /// Confirm deletion of the corrupt managed index + #[arg(long, required = true)] + confirm: bool, + }, +} + +pub(crate) async fn run(action: Option) -> Result<()> { + let workspace = std::env::current_dir().ok().map(PathBuf::from); + match action.unwrap_or(HookAction::List { + refresh: false, + format: HookImportOutputFormat::Text, + }) { + HookAction::List { refresh, format } => { + let snapshot = bitfun_core::external_hook_import::external_hook_import_snapshot( + workspace.as_deref(), + refresh, + ) + .await + .map_err(operation_error)?; + print_value(format, &snapshot, render_snapshot(&snapshot)) + } + HookAction::Import { + source, + confirm, + format, + } => { + let source = SourceKey::from_stable_key(&source) + .ok_or_else(|| anyhow!("Invalid Hook source key: {}", escape(&source)))?; + preview_or_apply(workspace.as_deref(), source, confirm, format).await + } + HookAction::Update { + import_id, + confirm, + format, + } => { + let snapshot = bitfun_core::external_hook_import::external_hook_import_snapshot( + workspace.as_deref(), + false, + ) + .await + .map_err(operation_error)?; + let source = snapshot + .imports + .iter() + .find(|item| item.import_id == import_id) + .map(|item| item.source.key.clone()) + .ok_or_else(|| anyhow!("Hook import not found: {}", escape(&import_id)))?; + preview_or_apply(workspace.as_deref(), source, confirm, format).await + } + HookAction::Enable { import_id } => { + mutate( + workspace.as_deref(), + ExternalHookImportMutationV1::SetEnabled { + import_id: import_id.clone(), + enabled: true, + }, + ) + .await + .map_err(operation_error)?; + println!("Enabled imported Hooks: {}", escape(&import_id)); + Ok(()) + } + HookAction::Disable { import_id } => { + mutate( + workspace.as_deref(), + ExternalHookImportMutationV1::SetEnabled { + import_id: import_id.clone(), + enabled: false, + }, + ) + .await + .map_err(operation_error)?; + println!("Disabled imported Hooks: {}", escape(&import_id)); + Ok(()) + } + HookAction::Remove { import_id, .. } => { + mutate( + workspace.as_deref(), + ExternalHookImportMutationV1::Remove { + import_id: import_id.clone(), + }, + ) + .await + .map_err(operation_error)?; + println!( + "Removed BitFun's managed Hook copy; the source was not changed: {}", + escape(&import_id) + ); + Ok(()) + } + HookAction::Reset { scope, .. } => { + let scope = match scope { + HookImportResetScope::User => ExternalSourceScope::UserGlobal, + HookImportResetScope::Project => ExternalSourceScope::Project, + }; + mutate( + workspace.as_deref(), + ExternalHookImportMutationV1::ResetCorruptStore { scope }, + ) + .await + .map_err(operation_error)?; + println!( + "Reset the corrupt {:?} managed Hook index; source files were not changed.", + scope + ); + Ok(()) + } + } +} + +async fn preview_or_apply( + workspace: Option<&std::path::Path>, + source: SourceKey, + confirm: Option, + format: HookImportOutputFormat, +) -> Result<()> { + let plan = + bitfun_core::external_hook_import::plan_external_hook_import(workspace, source.clone()) + .await + .map_err(operation_error)?; + let Some(plan_fingerprint) = confirm else { + return print_value(format, &plan, render_plan(&plan)); + }; + let result = bitfun_core::external_hook_import::apply_external_hook_import( + workspace, + ExternalHookImportApplyRequestV1 { + schema_version: EXTERNAL_HOOK_IMPORT_SCHEMA_V1, + source: source.clone(), + plan_fingerprint, + }, + ) + .await + .map_err(operation_error)?; + let text = match &result.outcome { + bitfun_product_domains::external_hook_import::ExternalHookImportApplyOutcomeV1::Applied { + snapshot, + } => completed_import_status(snapshot, &source, true).to_string(), + bitfun_product_domains::external_hook_import::ExternalHookImportApplyOutcomeV1::Unchanged { + snapshot, + } => completed_import_status(snapshot, &source, false).to_string(), + bitfun_product_domains::external_hook_import::ExternalHookImportApplyOutcomeV1::Stale { + refreshed_plan, + } => format!( + "The Hook source changed; nothing was written. Review the refreshed plan:\n{}", + render_plan(refreshed_plan) + ), + }; + print_value(format, &result, text) +} + +pub(crate) fn completed_import_status( + snapshot: &ExternalHookImportSnapshotV1, + source: &SourceKey, + applied: bool, +) -> &'static str { + let enabled = snapshot + .imports + .iter() + .find(|item| item.source.key == *source) + .map(|item| item.enabled); + match (applied, enabled) { + (true, Some(true)) => { + "Imported Hooks are enabled. The main Hook switch still controls whether they run." + } + (true, Some(false)) => "Imported Hooks were updated and remain disabled.", + (false, Some(true)) => { + "The reviewed Hook import is current and enabled. The main Hook switch still controls whether it runs." + } + (false, Some(false)) => "The reviewed Hook import is already current and remains disabled.", + (_, None) => "Hook import completed; refresh the Hook list to verify its current state.", + } +} + +pub(crate) async fn mutate( + workspace: Option<&std::path::Path>, + action: ExternalHookImportMutationV1, +) -> std::result::Result { + let snapshot = + bitfun_core::external_hook_import::external_hook_import_snapshot(workspace, false).await?; + bitfun_core::external_hook_import::mutate_external_hook_import( + workspace, + ExternalHookImportMutationRequestV1 { + schema_version: EXTERNAL_HOOK_IMPORT_SCHEMA_V1, + expected_revision: snapshot.revision, + action, + }, + ) + .await +} + +fn render_snapshot(snapshot: &ExternalHookImportSnapshotV1) -> String { + let mut lines = vec![format!( + "Available Hook sources ({}):", + snapshot.catalog.sources.len() + )]; + for source in &snapshot.catalog.sources { + lines.push(format!( + "- {} [{}] ({:?}, {:?})", + escape(&source.display_name), + escape(&source.key.stable_key()), + source.scope, + source.health + )); + } + lines.push(format!( + "Imported Hook sources ({}):", + snapshot.imports.len() + )); + for imported in &snapshot.imports { + lines.push(format!( + "- {} [{}] enabled={} state={:?}", + escape(&imported.source.display_name), + escape(&imported.import_id), + imported.enabled, + imported.state + )); + } + for diagnostic in &snapshot.diagnostics { + lines.push(format!( + "- diagnostic {}: {}", + escape(&diagnostic.code), + escape(&diagnostic.message) + )); + } + lines.join("\n") +} + +fn render_plan_lines(plan: &ExternalHookImportPlanV1) -> Vec { + let mut lines = vec![format!( + "Hook import {:?}: {} handler(s) from {}", + plan.disposition, + plan.handlers.len(), + escape(&plan.source.display_name) + )]; + for handler in &plan.handlers { + lines.push(format!( + "- {} event={} matcher={} timeout={}s", + escape(&handler.stable_key), + escape(&handler.event), + handler + .matcher + .as_deref() + .map(escape) + .unwrap_or_else(|| "*".to_string()), + handler.timeout_seconds.unwrap_or(60) + )); + lines.push(format!(" command: {}", escape(&handler.command))); + if let Some(command) = &handler.command_windows { + lines.push(format!(" commandWindows: {}", escape(command))); + } + for dependency in &handler.dependencies { + let (kind, value) = match dependency { + ExternalHookImportDependencyV1::Managed { relative_path } => { + ("managed", relative_path) + } + ExternalHookImportDependencyV1::External { location } => ("external", location), + }; + lines.push(format!(" dependency ({kind}): {}", escape(value))); + } + } + for skipped in &plan.skipped { + lines.push(format!( + "- skipped {}: {}", + escape(&skipped.reason_code), + skipped.count + )); + } + lines +} + +pub(crate) fn render_plan(plan: &ExternalHookImportPlanV1) -> String { + let mut lines = render_plan_lines(plan); + lines.push(format!( + "Plan fingerprint: {}", + escape(&plan.plan_fingerprint) + )); + lines.push("Preview only until this exact fingerprint is passed with --confirm.".to_string()); + lines.join("\n") +} + +pub(crate) fn render_plan_for_tui(plan: &ExternalHookImportPlanV1) -> String { + let mut lines = render_plan_lines(plan); + lines.push( + "Preview only; repeat the same import/update command with --confirm after review." + .to_string(), + ); + lines.join("\n") +} + +fn print_value(format: HookImportOutputFormat, value: &impl Serialize, text: String) -> Result<()> { + match format { + HookImportOutputFormat::Text => println!("{text}"), + HookImportOutputFormat::Json => println!("{}", serde_json::to_string(value)?), + } + Ok(()) +} + +fn operation_error(error: ExternalSourceOperationError) -> anyhow::Error { + anyhow!("{}: {}", error.code.as_str(), escape(&error.detail)) +} + +fn escape(value: &str) -> String { + crate::plugin_diagnostics::escape_terminal_text(value) +} + +#[cfg(test)] +mod tests { + use super::*; + use bitfun_product_domains::external_hook_catalog::{ + ExternalHookCatalogSnapshotV1, ExternalHookSource, ExternalHookSourceKind, + }; + use bitfun_product_domains::external_hook_import::{ + ExternalHookImportDispositionV1, ImportedHookSourceSnapshotV1, ImportedHookSourceStateV1, + }; + use bitfun_product_domains::external_sources::{ + EcosystemId, ExternalSourceHealth, ExternalSourceScope, + }; + + fn disabled_snapshot() -> (SourceKey, ExternalHookImportSnapshotV1) { + let source_key = SourceKey::new("claude", "user-hooks").unwrap(); + let source = ExternalHookSource { + key: source_key.clone(), + ecosystem_id: EcosystemId::new("claude-code").unwrap(), + display_name: "Claude Code user Hooks".to_string(), + source_kind: ExternalHookSourceKind::Settings, + scope: ExternalSourceScope::UserGlobal, + location_hint: "user settings".to_string(), + health: ExternalSourceHealth::Available, + content_version: "sha256:source".to_string(), + diagnostics: Vec::new(), + }; + ( + source_key, + ExternalHookImportSnapshotV1 { + schema_version: EXTERNAL_HOOK_IMPORT_SCHEMA_V1, + revision: "sha256:revision".to_string(), + catalog: ExternalHookCatalogSnapshotV1::default(), + imports: vec![ImportedHookSourceSnapshotV1 { + import_id: "hook-import".to_string(), + source, + enabled: false, + behavior_version: "sha256:behavior".to_string(), + state: ImportedHookSourceStateV1::Current, + }], + diagnostics: Vec::new(), + }, + ) + } + + #[test] + fn completed_update_reports_that_the_import_remains_disabled() { + let (source, snapshot) = disabled_snapshot(); + + assert_eq!( + completed_import_status(&snapshot, &source, true), + "Imported Hooks were updated and remain disabled." + ); + } + + #[test] + fn unchanged_import_reports_that_it_remains_disabled() { + let (source, snapshot) = disabled_snapshot(); + + assert_eq!( + completed_import_status(&snapshot, &source, false), + "The reviewed Hook import is already current and remains disabled." + ); + } + + #[test] + fn enabled_completion_does_not_claim_the_global_hook_switch_is_on() { + let (source, mut snapshot) = disabled_snapshot(); + snapshot.imports[0].enabled = true; + + assert_eq!( + completed_import_status(&snapshot, &source, true), + "Imported Hooks are enabled. The main Hook switch still controls whether they run." + ); + } + + #[test] + fn tui_plan_hides_the_internal_fingerprint_and_uses_its_own_confirmation_hint() { + let (_, snapshot) = disabled_snapshot(); + let plan = ExternalHookImportPlanV1 { + schema_version: EXTERNAL_HOOK_IMPORT_SCHEMA_V1, + source: snapshot.imports[0].source.clone(), + disposition: ExternalHookImportDispositionV1::Update, + behavior_version: "sha256:behavior".to_string(), + handlers: Vec::new(), + skipped: Vec::new(), + plan_fingerprint: "sha256:internal-plan".to_string(), + }; + + assert!(render_plan(&plan).contains("sha256:internal-plan")); + let rendered = render_plan_for_tui(&plan); + assert!(!rendered.contains("sha256:internal-plan")); + assert!(rendered.contains("repeat the same import/update command with --confirm")); + } +} diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs index 0b7146e1ff..6dae37650a 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -18,6 +18,7 @@ mod chat_state; mod config; mod daemon; mod diagnostics; +mod hook_import; mod logging; mod management; mod mcp_import; @@ -41,6 +42,7 @@ use std::sync::{Arc, OnceLock}; use agent::runtime_client::CliAgentRuntimeClient; use config::CliConfig; +use hook_import::HookAction; use mcp_import::{McpImportCommand, McpImportOutputFormat}; use modes::chat::ChatMode; use modes::exec::{ExecApprovalMode, ExecOutputFormat}; @@ -218,6 +220,12 @@ enum Commands { action: Option, }, + /// Review and manage imported Claude Code and Codex command Hooks + Hooks { + #[command(subcommand)] + action: Option, + }, + /// Usage reporting Usage { /// Session ID to inspect; defaults to the most recent session in the current workspace @@ -1132,6 +1140,10 @@ async fn run_cli() -> Result<()> { } }, + Some(Commands::Hooks { action }) => { + hook_import::run(action).await?; + } + Some(Commands::Usage { session_id }) => { management::print_usage_report(session_id.as_deref()).await?; } @@ -1593,6 +1605,66 @@ mod final_change_verification_cli_tests { } } +#[cfg(test)] +mod hook_import_command_tests { + use super::{Cli, Commands}; + use crate::hook_import::{HookAction, HookImportOutputFormat}; + use clap::Parser; + + #[test] + fn hook_import_is_preview_only_without_a_fingerprint() { + let cli = Cli::try_parse_from([ + "bitfun", + "hooks", + "import", + "--source", + "6:codex6:global", + "--format", + "json", + ]) + .expect("parse Hook import preview"); + assert!(matches!( + cli.command, + Some(Commands::Hooks { + action: Some(HookAction::Import { + confirm: None, + format: HookImportOutputFormat::Json, + .. + }) + }) + )); + } + + #[test] + fn hook_remove_requires_explicit_confirmation() { + assert!(Cli::try_parse_from(["bitfun", "hooks", "remove", "import-id"]).is_err()); + let cli = Cli::try_parse_from(["bitfun", "hooks", "remove", "import-id", "--confirm"]) + .expect("parse confirmed Hook removal"); + assert!(matches!( + cli.command, + Some(Commands::Hooks { + action: Some(HookAction::Remove { .. }) + }) + )); + } + + #[test] + fn corrupt_hook_store_reset_requires_an_explicit_scope_and_confirmation() { + assert!(Cli::try_parse_from(["bitfun", "hooks", "reset", "user"]).is_err()); + let cli = Cli::try_parse_from(["bitfun", "hooks", "reset", "project", "--confirm"]) + .expect("parse confirmed project Hook store reset"); + assert!(matches!( + cli.command, + Some(Commands::Hooks { + action: Some(HookAction::Reset { + scope: crate::hook_import::HookImportResetScope::Project, + .. + }) + }) + )); + } +} + #[cfg(test)] mod sdk_host_command_tests { use super::Cli; diff --git a/src/apps/cli/src/modes/chat.rs b/src/apps/cli/src/modes/chat.rs index 35ea12d9e7..e37336a8b3 100644 --- a/src/apps/cli/src/modes/chat.rs +++ b/src/apps/cli/src/modes/chat.rs @@ -64,8 +64,8 @@ use bitfun_core::agentic::tools::implementations::skills::{ ModeSkillInfo, SkillInfo, }; use bitfun_core::external_hooks::{ - local_external_hook_catalog_snapshot, ExternalHookCatalogSnapshotV1, - ExternalHookMatcherSummary, ExternalHookNativeActivation, ExternalHookProjectionStatus, + ExternalHookCatalogSnapshotV1, ExternalHookMatcherSummary, ExternalHookNativeActivation, + ExternalHookProjectionStatus, }; use bitfun_core::external_sources::{ apply_external_source_control_action, choose_external_subagent_conflict, @@ -88,7 +88,14 @@ use bitfun_core::native_hooks::{ use bitfun_core::product_runtime::CoreAgentRuntimeCompatibility; use bitfun_core::service::config::GlobalConfigManager; use bitfun_core::service::session_usage::render_usage_report_markdown; -use bitfun_product_domains::external_sources::{ExternalSourceHealth, ExternalSourceScope}; +use bitfun_product_domains::external_hook_import::{ + ExternalHookImportApplyOutcomeV1, ExternalHookImportApplyRequestV1, + ExternalHookImportMutationV1, ExternalHookImportPlanV1, ExternalHookImportSnapshotV1, + EXTERNAL_HOOK_IMPORT_SCHEMA_V1, +}; +use bitfun_product_domains::external_sources::{ + ExternalSourceHealth, ExternalSourceScope, SourceKey, +}; /// Spinner/UI redraw interval while a turn is processing. const SPINNER_REDRAW_INTERVAL_MS: u64 = 100; @@ -221,14 +228,16 @@ pub(crate) struct ChatMode { external_agent_notice_key: Option, external_agent_review_snapshot: Option, external_agent_mutation_rx: Option>, - external_hook_catalog_rx: Option< + hook_management_rx: Option< Receiver< std::result::Result< - ExternalHookCatalogSnapshotV1, + HookManagementResult, bitfun_core::external_sources::ExternalSourceOperationError, >, >, >, + hook_management_snapshot: Option, + pending_hook_plan: Option, } /// Map agent_type to a display name for status messages @@ -273,7 +282,9 @@ impl ChatMode { external_agent_notice_key: None, external_agent_review_snapshot: None, external_agent_mutation_rx: None, - external_hook_catalog_rx: None, + hook_management_rx: None, + hook_management_snapshot: None, + pending_hook_plan: None, } } diff --git a/src/apps/cli/src/modes/chat/commands.rs b/src/apps/cli/src/modes/chat/commands.rs index 60f05c759f..d31d47600f 100644 --- a/src/apps/cli/src/modes/chat/commands.rs +++ b/src/apps/cli/src/modes/chat/commands.rs @@ -287,6 +287,16 @@ impl ChatMode { self.handle_external_control(arguments, chat_view, chat_state, rt_handle); return Ok(None); } + let can_route_hook_management = builtin_action.is_some_and(|action| { + matches!( + action.handler, + ActionHandler::NativeHooks | ActionHandler::ExternalHooks + ) + }) && native_management_available; + if can_route_hook_management { + self.handle_hook_management(arguments, chat_view, chat_state, rt_handle); + return Ok(None); + } if external.is_none() && !unresolved_candidates.is_empty() && !native_choice_is_active { let choices = unresolved_candidates .iter() @@ -686,11 +696,8 @@ impl ChatMode { ActionHandler::Extensions => { self.handle_external_control("", chat_view, chat_state, rt_handle); } - ActionHandler::NativeHooks => { - self.handle_native_hooks(chat_view, chat_state, rt_handle); - } - ActionHandler::ExternalHooks => { - self.handle_external_hooks(chat_view, chat_state, rt_handle); + ActionHandler::NativeHooks | ActionHandler::ExternalHooks => { + self.handle_hook_management("", chat_view, chat_state, rt_handle); } ActionHandler::AcpHelp => { chat_state.add_system_message(crate::acp_cli::acp_help_text("bitfun")); diff --git a/src/apps/cli/src/modes/chat/external_hooks.rs b/src/apps/cli/src/modes/chat/external_hooks.rs index 217be9a476..3ee530c48c 100644 --- a/src/apps/cli/src/modes/chat/external_hooks.rs +++ b/src/apps/cli/src/modes/chat/external_hooks.rs @@ -3,20 +3,91 @@ 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; +#[derive(Debug, Clone)] +struct HookManagementSnapshot { + native: NativeHookOverview, + imports: ExternalHookImportSnapshotV1, +} + +enum HookManagementResult { + Snapshot(HookManagementSnapshot), + Plan(ExternalHookImportPlanV1), + Changed { + snapshot: HookManagementSnapshot, + status: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum HookManagementAction { + Show { refresh: bool }, + Import { source_number: usize, confirm: bool }, + Update { import_number: usize, confirm: bool }, + Enable { import_number: usize }, + Disable { import_number: usize }, + Remove { import_number: usize }, + Reset { scope: ExternalSourceScope }, +} + +fn parse_hook_management_action(arguments: &str) -> Result { + let parts = arguments.split_whitespace().collect::>(); + match parts.as_slice() { + [] => Ok(HookManagementAction::Show { refresh: false }), + ["refresh"] => Ok(HookManagementAction::Show { refresh: true }), + ["reset", scope] => Err(format!( + "Resetting a corrupt managed index requires: /hooks reset {scope} --confirm" + )), + ["reset", scope, "--confirm"] => match *scope { + "user" => Ok(HookManagementAction::Reset { + scope: ExternalSourceScope::UserGlobal, + }), + "project" => Ok(HookManagementAction::Reset { + scope: ExternalSourceScope::Project, + }), + _ => Err("Hook reset scope must be user or project".to_string()), + }, + [action, number] | [action, number, "--confirm"] => { + let number = number + .parse::() + .ok() + .filter(|number| *number > 0) + .ok_or_else(|| "Hook source/import number must be 1 or greater".to_string())?; + let confirm = parts.len() == 3; + match *action { + "import" => Ok(HookManagementAction::Import { + source_number: number, + confirm, + }), + "update" => Ok(HookManagementAction::Update { + import_number: number, + confirm, + }), + "enable" if !confirm => Ok(HookManagementAction::Enable { + import_number: number, + }), + "disable" if !confirm => Ok(HookManagementAction::Disable { + import_number: number, + }), + "remove" if confirm => Ok(HookManagementAction::Remove { + import_number: number, + }), + "remove" => Err( + "Removing a managed copy requires: /hooks remove --confirm" + .to_string(), + ), + _ => Err(hook_management_usage()), + } + } + _ => Err(hook_management_usage()), + } +} + +fn hook_management_usage() -> String { + "Usage: /hooks [refresh | import [--confirm] | update [--confirm] | enable | disable | remove --confirm | reset --confirm]".to_string() +} + fn external_hook_help_text() -> String { - [ - "External Hooks", - "", - "Usage: /hooks_external", - "Alias: /hooks-external", - "", - "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.", - "BitFun's own Hooks, which do run, are shown by /hooks.", - "", - "Help: /help hooks_external, /hooks_external -h, or /hooks_external --help", - ] - .join("\n") + native_hook_help_text() } fn extension_command_help_request(command_name: &str, arguments: &str) -> Option { @@ -29,8 +100,7 @@ fn extension_command_help_request(command_name: &str, arguments: &str) -> Option return None; }; match requested.to_ascii_lowercase().as_str() { - "hooks" => Some(native_hook_help_text()), - "hooks_external" | "hooks-external" => Some(external_hook_help_text()), + "hooks" | "hooks_external" | "hooks-external" => Some(external_hook_help_text()), "extensions" => Some([ "External integrations", "", @@ -75,12 +145,13 @@ fn extension_command_help_request(command_name: &str, arguments: &str) -> Option fn render_external_hook_catalog(snapshot: &ExternalHookCatalogSnapshotV1) -> String { let mut lines = vec![ - "External Hooks (read-only)".to_string(), - "Static configuration only; no handler was loaded or executed.".to_string(), + "Available external Hook sources".to_string(), + "Discovery is read-only. Review an exact import plan before BitFun copies or enables anything." + .to_string(), String::new(), ]; if snapshot.discovery_pending { - lines.push("Hook discovery is still pending. Run /hooks_external again.".to_string()); + lines.push("Hook discovery is still pending. Run /hooks refresh again.".to_string()); return lines.join("\n"); } if snapshot.sources.is_empty() @@ -116,7 +187,7 @@ fn render_external_hook_catalog(snapshot: &ExternalHookCatalogSnapshotV1) -> Str .any(|provider_id| provider_id == &provider.provider_id); lines.push(format!( "{}: {} Hook{}, {} source{}{}", - provider.display_name, + crate::plugin_diagnostics::escape_terminal_text(&provider.display_name), provider_entry_count, plural(provider_entry_count), provider_sources.len(), @@ -145,12 +216,19 @@ fn render_external_hook_catalog(snapshot: &ExternalHookCatalogSnapshotV1) -> Str .iter() .take(MAX_TUI_HOOK_SOURCES_PER_PROVIDER) { + let source_number = snapshot + .sources + .iter() + .position(|candidate| candidate.key == source.key) + .map(|index| index + 1) + .unwrap_or(0); lines.push(format!( - " {} [{}; {}; {}]", - source.display_name, + " {source_number}. {} [{}; {}; {}; key: {}]", + crate::plugin_diagnostics::escape_terminal_text(&source.display_name), source_scope_label(source.scope), source_health_label(source.health), - source.location_hint, + crate::plugin_diagnostics::escape_terminal_text(&source.location_hint), + crate::plugin_diagnostics::escape_terminal_text(&source.key.stable_key()), )); for entry in snapshot .entries @@ -160,11 +238,13 @@ fn render_external_hook_catalog(snapshot: &ExternalHookCatalogSnapshotV1) -> Str { lines.push(format!( " - {} [{}; {}; {}; matcher: {}]", - entry.native_event, + crate::plugin_diagnostics::escape_terminal_text(&entry.native_event), hook_handler_label(entry.handler_kind), projection_label(entry), native_activation_label(entry.native_activation), - matcher_label(&entry.matcher), + crate::plugin_diagnostics::escape_terminal_text(&matcher_label( + &entry.matcher + )), )); rendered_entries += 1; } @@ -173,7 +253,11 @@ fn render_external_hook_catalog(snapshot: &ExternalHookCatalogSnapshotV1) -> Str .iter() .take(MAX_TUI_HOOK_DIAGNOSTICS_PER_PROVIDER - rendered_diagnostics) { - lines.push(format!(" ! {}: {}", diagnostic.code, diagnostic.message)); + lines.push(format!( + " ! {}: {}", + crate::plugin_diagnostics::escape_terminal_text(&diagnostic.code), + crate::plugin_diagnostics::escape_terminal_text(&diagnostic.message) + )); rendered_diagnostics += 1; } } @@ -199,7 +283,10 @@ fn render_external_hook_catalog(snapshot: &ExternalHookCatalogSnapshotV1) -> Str .filter(|entry| !source_by_key.contains_key(&entry.source)) .take(MAX_TUI_HOOK_ENTRIES_PER_PROVIDER) { - lines.push(format!("External: {}", entry.native_event)); + lines.push(format!( + "External: {}", + crate::plugin_diagnostics::escape_terminal_text(&entry.native_event) + )); } } let catalog_diagnostics = snapshot @@ -214,7 +301,11 @@ fn render_external_hook_catalog(snapshot: &ExternalHookCatalogSnapshotV1) -> Str .iter() .take(MAX_TUI_HOOK_CATALOG_DIAGNOSTICS) { - lines.push(format!(" - {}: {}", diagnostic.code, diagnostic.message)); + lines.push(format!( + " - {}: {}", + crate::plugin_diagnostics::escape_terminal_text(&diagnostic.code), + crate::plugin_diagnostics::escape_terminal_text(&diagnostic.message) + )); } if catalog_diagnostics.len() > MAX_TUI_HOOK_CATALOG_DIAGNOSTICS { lines.push(format!( @@ -225,7 +316,7 @@ fn render_external_hook_catalog(snapshot: &ExternalHookCatalogSnapshotV1) -> Str } lines.push(String::new()); lines.push( - "Edit Hooks in the source application's configuration. BitFun's own Hooks: /hooks. Help: /help hooks_external, /hooks_external -h, or /hooks_external --help" + "Preview with /hooks import . The source application's files remain unchanged." .to_string(), ); lines.join("\n") @@ -311,76 +402,464 @@ fn plural(count: usize) -> &'static str { } } +fn render_hook_management(snapshot: &HookManagementSnapshot) -> String { + let mut sections = vec![render_native_hook_overview(&snapshot.native)]; + let mut imported = vec![format!( + "Imported Hook sources ({})", + snapshot.imports.imports.len() + )]; + if snapshot.imports.imports.is_empty() { + imported.push(" None. Available sources can be reviewed below.".to_string()); + } + for (index, item) in snapshot.imports.imports.iter().enumerate() { + imported.push(format!( + " {}. {} [{}; {}; state: {:?}]", + index + 1, + crate::plugin_diagnostics::escape_terminal_text(&item.source.display_name), + crate::plugin_diagnostics::escape_terminal_text(&item.import_id), + if item.enabled { "enabled" } else { "disabled" }, + item.state, + )); + } + for diagnostic in &snapshot.imports.diagnostics { + imported.push(format!( + " ! {}: {}", + crate::plugin_diagnostics::escape_terminal_text(&diagnostic.code), + crate::plugin_diagnostics::escape_terminal_text(&diagnostic.message), + )); + } + imported.push("Manage with /hooks update|enable|disable|remove .".to_string()); + sections.push(imported.join("\n")); + sections.push(render_external_hook_catalog(&snapshot.imports.catalog)); + sections.join("\n\n") +} + impl ChatMode { - fn handle_external_hooks( + fn handle_hook_management( &mut self, + arguments: &str, 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())); + if self.hook_management_rx.is_some() { + chat_view.set_status(Some( + "A Hook management operation is already in progress".to_string(), + )); return; } + let action = match parse_hook_management_action(arguments) { + Ok(action) => action, + Err(message) => { + chat_state.add_system_message(message); + 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( + match action { + HookManagementAction::Show { refresh } => { + if let Some(snapshot) = &self.hook_management_snapshot { + chat_state.add_system_message(render_hook_management(snapshot)); + } + self.spawn_hook_management( + async move { + let imports = + bitfun_core::external_hook_import::external_hook_import_snapshot( + Some(workspace_root.as_path()), + refresh, + ) + .await?; + let native = native_hook_overview(Some(workspace_root.as_path())).await; + Ok(HookManagementResult::Snapshot(HookManagementSnapshot { + native, + imports, + })) + }, + if refresh { + "Refreshing Hooks..." + } else { + "Loading Hooks..." + }, + chat_view, + rt_handle, + ); + } + HookManagementAction::Import { + source_number, + confirm, + } => { + let Some(snapshot) = &self.hook_management_snapshot else { + chat_state.add_system_message( + "Run /hooks first to load available sources.".to_string(), + ); + return; + }; + let Some(source) = snapshot + .imports + .catalog + .sources + .get(source_number - 1) + .map(|source| source.key.clone()) + else { + chat_state.add_system_message(format!( + "Hook source {source_number} is not available. Run /hooks refresh." + )); + return; + }; + self.start_hook_plan_or_apply( + source, + confirm, + workspace_root, + chat_view, + chat_state, + rt_handle, + ); + } + HookManagementAction::Update { + import_number, + confirm, + } => { + let Some(source) = self + .import_at(import_number, chat_state) + .map(|item| item.source.key.clone()) + else { + return; + }; + self.start_hook_plan_or_apply( + source, + confirm, + workspace_root, + chat_view, + chat_state, + rt_handle, + ); + } + HookManagementAction::Enable { import_number } => { + self.start_hook_mutation( + import_number, + true, + false, + workspace_root, + chat_view, + chat_state, + rt_handle, + ); + } + HookManagementAction::Disable { import_number } => { + self.start_hook_mutation( + import_number, + false, + false, + workspace_root, + chat_view, + chat_state, + rt_handle, + ); + } + HookManagementAction::Remove { import_number } => { + self.start_hook_mutation( + import_number, + false, + true, + workspace_root, + chat_view, + chat_state, + rt_handle, + ); + } + HookManagementAction::Reset { scope } => { + self.start_hook_store_reset( + scope, + workspace_root, + chat_view, + chat_state, + rt_handle, + ); + } + } + } + + fn start_hook_plan_or_apply( + &mut self, + source: SourceKey, + confirm: bool, + workspace_root: std::path::PathBuf, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + if !confirm { + self.spawn_hook_management( + async move { + bitfun_core::external_hook_import::plan_external_hook_import( + Some(workspace_root.as_path()), + source, + ) + .await + .map(HookManagementResult::Plan) + }, + "Preparing Hook import review...", + chat_view, + rt_handle, + ); + return; + } + let Some(plan) = self + .pending_hook_plan + .as_ref() + .filter(|plan| plan.source.key == source) + .cloned() + else { + chat_state.add_system_message( + "Preview this Hook source first, then repeat the same command with --confirm." + .to_string(), + ); + return; + }; + self.spawn_hook_management( + async move { + let result = bitfun_core::external_hook_import::apply_external_hook_import( Some(workspace_root.as_path()), - force_refresh, + ExternalHookImportApplyRequestV1 { + schema_version: EXTERNAL_HOOK_IMPORT_SCHEMA_V1, + source: source.clone(), + plan_fingerprint: plan.plan_fingerprint, + }, ) - .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); + .await?; + let (snapshot, applied) = match result.outcome { + ExternalHookImportApplyOutcomeV1::Stale { refreshed_plan } => { + return Ok(HookManagementResult::Plan(refreshed_plan)); + } + ExternalHookImportApplyOutcomeV1::Applied { snapshot } => (snapshot, true), + ExternalHookImportApplyOutcomeV1::Unchanged { snapshot } => (snapshot, false), + }; + let status = + crate::hook_import::completed_import_status(&snapshot, &source, applied) + .to_string(); + let native = native_hook_overview(Some(workspace_root.as_path())).await; + Ok(HookManagementResult::Changed { + snapshot: HookManagementSnapshot { + native, + imports: snapshot, + }, + status, + }) + }, + "Applying reviewed Hook import...", + chat_view, + rt_handle, + ); + } + + fn import_at<'a>( + &'a self, + number: usize, + chat_state: &mut ChatState, + ) -> Option<&'a bitfun_product_domains::external_hook_import::ImportedHookSourceSnapshotV1> + { + let Some(snapshot) = &self.hook_management_snapshot else { + chat_state.add_system_message("Run /hooks first to load imported sources.".to_string()); + return None; + }; + let item = snapshot.imports.imports.get(number - 1); + if item.is_none() { + chat_state.add_system_message(format!( + "Hook import {number} is not available. Run /hooks refresh." + )); + } + item + } + + fn start_hook_mutation( + &mut self, + import_number: usize, + enabled: bool, + remove: bool, + workspace_root: std::path::PathBuf, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + let Some(item) = self.import_at(import_number, chat_state) else { + return; + }; + let import_id = item.import_id.clone(); + let action = if remove { + ExternalHookImportMutationV1::Remove { + import_id: import_id.clone(), + } + } else { + ExternalHookImportMutationV1::SetEnabled { + import_id: import_id.clone(), + enabled, + } + }; + self.spawn_hook_management( + async move { + let imports = + crate::hook_import::mutate(Some(workspace_root.as_path()), action).await?; + let native = native_hook_overview(Some(workspace_root.as_path())).await; + let status = if remove { + format!( + "Removed BitFun's managed copy of {import_id}; the source was unchanged." + ) + } else if enabled { + format!("Enabled {import_id} for the next matching event.") + } else { + format!("Disabled {import_id} for the next matching event.") + }; + Ok(HookManagementResult::Changed { + snapshot: HookManagementSnapshot { native, imports }, + status, + }) + }, + "Updating imported Hooks...", + chat_view, + rt_handle, + ); + } + + fn start_hook_store_reset( + &mut self, + scope: ExternalSourceScope, + workspace_root: std::path::PathBuf, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + let Some(snapshot) = &self.hook_management_snapshot else { + chat_state.add_system_message("Run /hooks first to inspect managed state.".to_string()); + return; + }; + let scope_key = match scope { + ExternalSourceScope::UserGlobal => "user_global", + ExternalSourceScope::Project => "project", + _ => { + chat_state + .add_system_message("Hook reset scope must be user or project.".to_string()); + return; + } + }; + let diagnostic_code = format!("external_hook.import_store_corrupt.{scope_key}"); + if !snapshot + .imports + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == diagnostic_code) + { + chat_state.add_system_message(format!( + "The {scope_key} managed Hook index is not corrupt; nothing was reset." + )); + return; + } + self.spawn_hook_management( + async move { + let imports = crate::hook_import::mutate( + Some(workspace_root.as_path()), + ExternalHookImportMutationV1::ResetCorruptStore { scope }, + ) + .await?; + let native = native_hook_overview(Some(workspace_root.as_path())).await; + Ok(HookManagementResult::Changed { + snapshot: HookManagementSnapshot { native, imports }, + status: format!( + "Reset the corrupt {scope_key} managed Hook index; source files were unchanged." + ), + }) + }, + "Resetting corrupt Hook state...", + chat_view, + rt_handle, + ); + } + + fn spawn_hook_management( + &mut self, + future: F, + status: &str, + chat_view: &mut ChatView, + rt_handle: &tokio::runtime::Handle, + ) where + F: std::future::Future< + Output = std::result::Result< + HookManagementResult, + bitfun_core::external_sources::ExternalSourceOperationError, + >, + > + Send + + 'static, + { + let (sender, receiver) = mpsc::channel(); + rt_handle.spawn(async move { + let _ = sender.send(future.await); }); - self.external_hook_catalog_rx = Some(receiver); - chat_view.set_status(Some("Refreshing Hook catalog...".to_string())); + self.hook_management_rx = Some(receiver); + chat_view.set_status(Some(status.to_string())); } - fn poll_external_hook_catalog( + fn poll_hook_management( &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) - { + let result = match self.hook_management_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())); + self.hook_management_rx = None; + chat_view.set_status(Some("Hook management operation failed".to_string())); chat_state.add_system_message( - "Hooks are unavailable because the background refresh ended unexpectedly." + "Hooks are unavailable because the background operation ended unexpectedly." .to_string(), ); return true; } }; - self.external_hook_catalog_rx = None; + self.hook_management_rx = None; match result { - Ok(snapshot) => { - chat_state.add_system_message(render_external_hook_catalog(&snapshot)); + Ok(HookManagementResult::Snapshot(snapshot)) => { + chat_state.add_system_message(render_hook_management(&snapshot)); chat_view.set_status(Some(format!( - "Hook catalog: {} sources, {} Hooks", - snapshot.sources.len(), - snapshot.entries.len(), + "Hooks: {} native/imported handlers, {} available sources", + snapshot.native.total_handlers, + snapshot.imports.catalog.sources.len(), ))); + self.hook_management_snapshot = Some(snapshot); + } + Ok(HookManagementResult::Plan(plan)) => { + chat_state.add_system_message(crate::hook_import::render_plan_for_tui(&plan)); + chat_view.set_status(Some( + "Review complete; repeat the same import/update command with --confirm." + .to_string(), + )); + self.pending_hook_plan = Some(plan); + } + Ok(HookManagementResult::Changed { snapshot, status }) => { + let status = crate::plugin_diagnostics::escape_terminal_text(&status); + chat_state.add_system_message(format!( + "{}\n\n{}", + status, + render_hook_management(&snapshot), + )); + chat_view.set_status(Some(status)); + self.hook_management_snapshot = Some(snapshot); + self.pending_hook_plan = None; + } + Err(error) + if error.code + == bitfun_core::external_sources::ExternalSourceOperationErrorCode::StaleRevision => + { + chat_state.add_system_message( + "Hook import state changed; the action was not applied. Run /hooks to refresh, review the new state, and try again." + .to_string(), + ); + chat_view.set_status(Some("Hook import state changed".to_string())); } Err(error) => { chat_state.add_system_message(format!( "Hooks are unavailable ({}): {}", error.code.as_str(), - error.detail, + crate::plugin_diagnostics::escape_terminal_text(&error.detail), )); chat_view.set_status(Some("Hook catalog unavailable".to_string())); } diff --git a/src/apps/cli/src/modes/chat/native_hooks.rs b/src/apps/cli/src/modes/chat/native_hooks.rs index 44a202b6f5..9b94c67d5f 100644 --- a/src/apps/cli/src/modes/chat/native_hooks.rs +++ b/src/apps/cli/src/modes/chat/native_hooks.rs @@ -7,11 +7,11 @@ fn native_hook_help_text() -> String { [ "Hooks", "", - "Usage: /hooks", + "Usage: /hooks [refresh | import [--confirm] | update [--confirm] | enable | disable | remove --confirm | reset --confirm]", "", - "Shows the BitFun Hooks configured for agent lifecycle events, which files they came from, and whether each layer is active.", - "Hooks run your own commands, so this view only reports the configuration; edit hooks.json to change it.", - "Hooks configured for other AI applications are a separate read-only catalog: /hooks_external.", + "Shows native BitFun Hooks plus compatible Claude Code and Codex command Hooks.", + "Import and update are preview-only until the exact reviewed plan is confirmed. Source files are never edited.", + "Compatibility aliases: /hooks_external and /hooks-external.", "", "Help: /help hooks, /hooks -h, or /hooks --help", ] @@ -33,7 +33,7 @@ fn truncate_hook_command(command: &str) -> String { fn native_hook_rule_line(rule: &NativeHookRuleView) -> String { format!( " matcher: {} [{}; {} handler{}{}]", - rule.matcher, + crate::plugin_diagnostics::escape_terminal_text(&rule.matcher), rule.scope, rule.handlers.len(), plural(rule.handlers.len()), @@ -61,7 +61,7 @@ fn render_native_hook_overview(overview: &NativeHookOverview) -> String { } )); lines.push(format!( - "Project hooks: {} (app.hooks.project_hooks_enabled)", + "Project hook file: {} (app.hooks.project_hooks_enabled)", if overview.project_hooks_enabled { "enabled" } else { @@ -80,7 +80,7 @@ fn render_native_hook_overview(overview: &NativeHookOverview) -> String { file.scope, if file.loaded { "loaded" } else { "not loaded" }, if file.exists { "present" } else { "missing" }, - file.path.display(), + crate::plugin_diagnostics::escape_terminal_text(&file.path.to_string_lossy()), )); } } @@ -103,7 +103,7 @@ fn render_native_hook_overview(overview: &NativeHookOverview) -> String { if rule.event != current_event { current_event = rule.event; lines.push(String::new()); - lines.push(rule.event.to_string()); + lines.push(crate::plugin_diagnostics::escape_terminal_text(rule.event)); } lines.push(native_hook_rule_line(rule)); for handler in rule @@ -113,11 +113,15 @@ fn render_native_hook_overview(overview: &NativeHookOverview) -> String { { lines.push(format!( " - {} [timeout {}s{}]", - truncate_hook_command(&handler.command), + truncate_hook_command(&crate::plugin_diagnostics::escape_terminal_text( + &handler.command, + )), handler.timeout_seconds, match handler.status_message.as_deref() { - Some(message) if !message.trim().is_empty() => - format!("; status: {}", message.trim()), + Some(message) if !message.trim().is_empty() => format!( + "; status: {}", + crate::plugin_diagnostics::escape_terminal_text(message.trim()) + ), _ => String::new(), }, )); @@ -146,7 +150,10 @@ fn render_native_hook_overview(overview: &NativeHookOverview) -> String { lines.push(String::new()); lines.push("Configuration issues:".to_string()); for issue in overview.issues.iter().take(MAX_TUI_NATIVE_HOOK_ISSUES) { - lines.push(format!(" ! {issue}")); + lines.push(format!( + " ! {}", + crate::plugin_diagnostics::escape_terminal_text(issue) + )); } if overview.issues.len() > MAX_TUI_NATIVE_HOOK_ISSUES { lines.push(format!( @@ -157,33 +164,6 @@ fn render_native_hook_overview(overview: &NativeHookOverview) -> String { } lines.push(String::new()); - lines.push( - "Edit hooks.json to change this. Hooks configured for other AI applications: /hooks_external. Help: /help hooks, /hooks -h, or /hooks --help" - .to_string(), - ); + lines.push("Manual Hooks remain editable in hooks.json. Imported Hooks are managed through /hooks. Help: /help hooks, /hooks -h, or /hooks --help".to_string()); lines.join("\n") } - -impl ChatMode { - fn handle_native_hooks( - &mut self, - chat_view: &mut ChatView, - chat_state: &mut ChatState, - rt_handle: &tokio::runtime::Handle, - ) { - let workspace_root = self.workspace_path_for_sync(chat_state); - let overview = tokio::task::block_in_place(|| { - rt_handle.block_on(native_hook_overview(Some(workspace_root.as_path()))) - }); - chat_view.set_status(Some(if overview.enabled { - format!( - "Hooks: {} handler{}", - overview.total_handlers, - plural(overview.total_handlers) - ) - } else { - "Hooks are disabled".to_string() - })); - chat_state.add_system_message(render_native_hook_overview(&overview)); - } -} diff --git a/src/apps/cli/src/modes/chat/run.rs b/src/apps/cli/src/modes/chat/run.rs index da60aa816b..5bd03d33fe 100644 --- a/src/apps/cli/src/modes/chat/run.rs +++ b/src/apps/cli/src/modes/chat/run.rs @@ -287,7 +287,7 @@ 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) { + if self.poll_hook_management(&mut chat_view, &mut chat_state) { needs_redraw = true; } diff --git a/src/apps/cli/src/modes/chat/tests.rs b/src/apps/cli/src/modes/chat/tests.rs index e75a90423a..d6d73a178d 100644 --- a/src/apps/cli/src/modes/chat/tests.rs +++ b/src/apps/cli/src/modes/chat/tests.rs @@ -15,12 +15,13 @@ mod tests { 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_choice_is_active, - native_command_reconfirmation_is_required, parse_external_agent_review_action, - parse_external_control_action, parse_external_tool_review_action, - native_hook_help_text, previous_session_mode_change_status, render_external_hook_catalog, - render_native_hook_overview, CommandRoute, - ExternalAgentReviewAction, ExternalControlUiAction, ExternalSourceConflictPreferences, - ExternalToolReviewAction, ModeSelectionApplyOutcome, ModelSelectionApplyOutcome, + native_command_reconfirmation_is_required, native_hook_help_text, + parse_external_agent_review_action, parse_external_control_action, + parse_external_tool_review_action, parse_hook_management_action, + previous_session_mode_change_status, render_external_hook_catalog, + render_native_hook_overview, CommandRoute, ExternalAgentReviewAction, + ExternalControlUiAction, ExternalSourceConflictPreferences, ExternalToolReviewAction, + HookManagementAction, ModeSelectionApplyOutcome, ModelSelectionApplyOutcome, }; use crate::actions::{ action_conflict_behavior_version, ActionHandler, ActionState, ResolvedKeymap, @@ -29,9 +30,6 @@ mod tests { use crate::config::ShortcutsConfig; use crate::ui::command_menu::{ExternalCommandProjection, NativeCommandCollisionProjection}; use bitfun_core::external_hooks::ExternalHookCatalogSnapshotV1; - use bitfun_core::native_hooks::{ - NativeHookFileView, NativeHookHandlerView, NativeHookOverview, NativeHookRuleView, - }; use bitfun_core::external_sources::{ native_prompt_command_conflict_key, ExternalSourceAssetKind, ExternalSourceCatalogSnapshot, ExternalSourceControlSnapshotV1, ExternalSourceDiagnostic, @@ -39,6 +37,10 @@ mod tests { ExternalSourceOperationErrorCode, ExternalSubagentActivationState, ExternalToolActivationState, }; + use bitfun_core::native_hooks::{ + NativeHookFileView, NativeHookHandlerView, NativeHookOverview, NativeHookRuleView, + }; + use bitfun_product_domains::external_sources::ExternalSourceScope; use std::collections::{BTreeMap, BTreeSet}; fn external_command( @@ -700,6 +702,37 @@ mod tests { ); } + #[test] + fn hooks_management_requires_an_explicit_second_step_for_writes() { + assert_eq!( + parse_hook_management_action("import 2").unwrap(), + HookManagementAction::Import { + source_number: 2, + confirm: false, + } + ); + assert_eq!( + parse_hook_management_action("update 1 --confirm").unwrap(), + HookManagementAction::Update { + import_number: 1, + confirm: true, + } + ); + assert!(parse_hook_management_action("remove 1").is_err()); + assert_eq!( + parse_hook_management_action("remove 1 --confirm").unwrap(), + HookManagementAction::Remove { import_number: 1 } + ); + assert!(parse_hook_management_action("reset user").is_err()); + assert_eq!( + parse_hook_management_action("reset project --confirm").unwrap(), + HookManagementAction::Reset { + scope: ExternalSourceScope::Project, + } + ); + assert!(parse_hook_management_action("enable 0").is_err()); + } + #[test] fn selected_external_help_keeps_its_hooks_argument() { let selected_external = external_command("help", Some("external:help")); @@ -768,7 +801,8 @@ mod tests { .unwrap(); let text = render_external_hook_catalog(&snapshot); - assert!(text.contains("External Hooks (read-only)")); + assert!(text.contains("Available external Hook sources")); + assert!(text.contains("Discovery is read-only")); assert!(text.contains("Claude Code")); assert!(text.contains("PreToolUse")); assert!(text.contains("coverage mapped: BitFun tool before")); @@ -820,15 +854,13 @@ mod tests { assert!(text.contains("Hooks (BitFun)")); assert!(text.contains("Hooks: enabled (app.hooks.enabled)")); - assert!(text.contains("Project hooks: disabled (app.hooks.project_hooks_enabled)")); + assert!(text.contains("Project hook file: disabled (app.hooks.project_hooks_enabled)")); assert!(text.contains("user [loaded; present]")); assert!(text.contains("project [not loaded; present]")); assert!(text.contains("PreToolUse")); assert!(text.contains("matcher: Bash [user; 1 handler]")); assert!(text.contains("timeout 600s")); assert!(text.contains("is not a supported event name")); - // The external catalog stays discoverable from this view. - assert!(text.contains("/hooks_external")); } #[test] @@ -859,11 +891,11 @@ mod tests { #[test] fn external_hooks_help_uses_the_established_slash_help_pattern() { let help = external_hook_help_text(); - assert!(help.contains("Usage: /hooks_external")); - assert!(help.contains("Alias: /hooks-external")); - assert!(help.contains("/help hooks_external")); - assert!(help.contains("/hooks_external -h")); - assert!(help.contains("/hooks_external --help")); + assert!(help.contains("Usage: /hooks")); + assert!(help.contains("Compatibility aliases: /hooks_external and /hooks-external")); + assert!(help.contains("/help hooks")); + assert!(help.contains("/hooks -h")); + assert!(help.contains("/hooks --help")); assert!(!help.contains("/builtin:hooks")); } diff --git a/src/apps/desktop/src/api/external_hooks_api.rs b/src/apps/desktop/src/api/external_hooks_api.rs index 28c938857f..f76fb50e78 100644 --- a/src/apps/desktop/src/api/external_hooks_api.rs +++ b/src/apps/desktop/src/api/external_hooks_api.rs @@ -4,6 +4,11 @@ use bitfun_core::external_hooks::{ local_external_hook_catalog_snapshot, ExternalHookCatalogSnapshotV1, }; use bitfun_core::external_sources::ExternalSourceOperationResult; +use bitfun_product_domains::external_hook_import::{ + ExternalHookImportApplyRequestV1, ExternalHookImportApplyResultV1, + ExternalHookImportMutationRequestV1, ExternalHookImportPlanV1, ExternalHookImportSnapshotV1, +}; +use bitfun_product_domains::external_sources::SourceKey; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -16,6 +21,35 @@ pub struct ExternalHookCatalogRequest { pub type ExternalHookCatalogResponse = ExternalHookCatalogSnapshotV1; +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalHookImportSnapshotRequest { + pub workspace_path: Option, + #[serde(default)] + pub refresh_updates: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PlanExternalHookImportRequest { + pub workspace_path: Option, + pub source: SourceKey, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ApplyExternalHookImportRequest { + pub workspace_path: Option, + pub import_request: ExternalHookImportApplyRequestV1, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct MutateExternalHookImportRequest { + pub workspace_path: Option, + pub mutation: ExternalHookImportMutationRequestV1, +} + #[tauri::command] pub async fn get_external_hook_catalog( request: ExternalHookCatalogRequest, @@ -26,9 +60,59 @@ pub async fn get_external_hook_catalog( local_external_hook_catalog_snapshot(workspace, request.force_refresh).await } +#[tauri::command] +pub async fn get_external_hook_import_snapshot( + request: ExternalHookImportSnapshotRequest, +) -> ExternalSourceOperationResult { + let workspace = + super::external_sources_api::require_local_workspace(request.workspace_path.as_deref()) + .await?; + bitfun_core::external_hook_import::external_hook_import_snapshot( + workspace, + request.refresh_updates, + ) + .await +} + +#[tauri::command] +pub async fn plan_external_hook_import_command( + request: PlanExternalHookImportRequest, +) -> ExternalSourceOperationResult { + let workspace = + super::external_sources_api::require_local_workspace(request.workspace_path.as_deref()) + .await?; + bitfun_core::external_hook_import::plan_external_hook_import(workspace, request.source).await +} + +#[tauri::command] +pub async fn apply_external_hook_import_command( + request: ApplyExternalHookImportRequest, +) -> ExternalSourceOperationResult { + let workspace = + super::external_sources_api::require_local_workspace(request.workspace_path.as_deref()) + .await?; + bitfun_core::external_hook_import::apply_external_hook_import(workspace, request.import_request) + .await +} + +#[tauri::command] +pub async fn mutate_external_hook_import_command( + request: MutateExternalHookImportRequest, +) -> ExternalSourceOperationResult { + let workspace = + super::external_sources_api::require_local_workspace(request.workspace_path.as_deref()) + .await?; + bitfun_core::external_hook_import::mutate_external_hook_import(workspace, request.mutation) + .await +} + #[cfg(test)] mod tests { - use super::ExternalHookCatalogRequest; + use super::{ + ApplyExternalHookImportRequest, ExternalHookCatalogRequest, + ExternalHookImportSnapshotRequest, MutateExternalHookImportRequest, + PlanExternalHookImportRequest, + }; #[test] fn request_uses_the_structured_camel_case_desktop_contract() { @@ -52,4 +136,43 @@ mod tests { .is_err() ); } + + #[test] + fn import_requests_reject_unknown_fields_and_keep_core_requests_nested() { + let snapshot: ExternalHookImportSnapshotRequest = serde_json::from_value( + serde_json::json!({ "workspacePath": "D:/workspace/project", "refreshUpdates": true }), + ) + .unwrap(); + assert!(snapshot.refresh_updates); + + let plan: PlanExternalHookImportRequest = serde_json::from_value(serde_json::json!({ + "workspacePath": "D:/workspace/project", + "source": { "providerId": "codex.hooks", "sourceId": "user" } + })) + .unwrap(); + assert_eq!(plan.source.source_id.as_str(), "user"); + + let apply = serde_json::json!({ + "workspacePath": "D:/workspace/project", + "importRequest": { + "schemaVersion": 1, + "source": { "providerId": "codex.hooks", "sourceId": "user" }, + "planFingerprint": "sha256:abc" + } + }); + assert!(serde_json::from_value::(apply.clone()).is_ok()); + let mut invalid_apply = apply; + invalid_apply["unexpected"] = serde_json::json!(true); + assert!(serde_json::from_value::(invalid_apply).is_err()); + + let mutation = serde_json::json!({ + "workspacePath": null, + "mutation": { + "schemaVersion": 1, + "expectedRevision": "sha256:abc", + "action": { "kind": "set_enabled", "importId": "hook-source", "enabled": false } + } + }); + assert!(serde_json::from_value::(mutation).is_ok()); + } } diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index 512b57a564..e2f47b6248 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -470,6 +470,22 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "get_external_hook_catalog", RemoteWorkspacePolicy::RemoteUnsupported, ), + ( + "get_external_hook_import_snapshot", + RemoteWorkspacePolicy::RemoteUnsupported, + ), + ( + "plan_external_hook_import_command", + RemoteWorkspacePolicy::RemoteUnsupported, + ), + ( + "apply_external_hook_import_command", + RemoteWorkspacePolicy::RemoteUnsupported, + ), + ( + "mutate_external_hook_import_command", + RemoteWorkspacePolicy::RemoteUnsupported, + ), ( "get_external_source_snapshot", RemoteWorkspacePolicy::RemoteUnsupported, @@ -1888,6 +1904,22 @@ mod tests { } } + #[test] + fn external_hook_import_commands_explicitly_reject_remote_workspaces() { + for command in [ + "get_external_hook_import_snapshot", + "plan_external_hook_import_command", + "apply_external_hook_import_command", + "mutate_external_hook_import_command", + ] { + assert_eq!( + remote_workspace_policy(command), + Some(RemoteWorkspacePolicy::RemoteUnsupported), + "{command} must never use local imported Hooks for a remote workspace" + ); + } + } + #[test] fn external_source_control_web_command_is_registered() { const COMMAND: &str = "get_external_source_control_snapshot"; diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 6e242f3ae5..248f0d84fb 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1170,6 +1170,10 @@ pub async fn run() { api::editor_ai_api::editor_ai_stream, api::editor_ai_api::editor_ai_cancel, get_external_hook_catalog, + get_external_hook_import_snapshot, + plan_external_hook_import_command, + apply_external_hook_import_command, + mutate_external_hook_import_command, get_external_source_snapshot, plan_external_mcp_import_command, apply_external_mcp_import_command, diff --git a/src/crates/adapters/claude-code-adapter/src/hook_source.rs b/src/crates/adapters/claude-code-adapter/src/hook_source.rs index 18945b14f5..6c614b3211 100644 --- a/src/crates/adapters/claude-code-adapter/src/hook_source.rs +++ b/src/crates/adapters/claude-code-adapter/src/hook_source.rs @@ -5,17 +5,24 @@ use bitfun_product_domains::external_hook_catalog::{ ExternalHookSourceKind, ExternalHookSourceProvider, }; use bitfun_product_domains::external_hook_contributions::ExternalHookPoint; +use bitfun_product_domains::external_hook_import::{ + ExternalHookImportSkippedV1, PreparedExternalHookAsset, PreparedExternalHookHandler, + PreparedExternalHookImport, +}; 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, + bounded_project_ancestors, importable_hook_matcher, optional_hook_string, + optional_positive_hook_u64, parse_hook_document, prepare_static_hook_command, + read_bounded_file, redacted_parse_content_version, regular_file_exists, required_hook_string, + static_hook_handler_fact, visit_hook_document, 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 = "claude-code.hooks"; @@ -239,6 +246,302 @@ impl ExternalHookSourceProvider for ClaudeCodeHookProvider { })?; Ok(snapshot) } + + fn prepare_import( + &self, + context: &ExternalSourceContext, + requested_source: &SourceKey, + expected_catalog_content_version: &str, + ) -> Result { + if requested_source.provider_id.as_str() != PROVIDER_ID { + return Err(import_error( + "claude.hook.import_provider_mismatch", + "The requested Hook source belongs to another provider", + )); + } + let layers = self.layers(context); + let Some(layer) = layers + .iter() + .find(|layer| layer.source_id == requested_source.source_id.as_str()) + else { + return Err(import_error( + "claude.hook.import_source_missing", + "The requested Claude Code Hook source is no longer available", + )); + }; + let bytes = match read_bounded_file(&layer.path, MAX_SETTINGS_FILE_BYTES) { + Ok(BoundedFileRead::Content(bytes)) => bytes, + Ok(BoundedFileRead::TooLarge) => { + return Err(import_error( + "claude.hook.import_source_too_large", + "Claude Code settings exceed the 1 MiB import limit", + )) + } + Err(error) => { + return Err(ExternalSourceProviderError::new( + "claude.hook.import_source_unreadable", + format!("Claude Code Hook settings could not be read: {error}"), + true, + )) + } + }; + let effective_disabled = effective_disable_for_import(&layers, &layer.path, &bytes)?; + prepare_claude_import( + layer, + requested_source.clone(), + &bytes, + expected_catalog_content_version, + effective_disabled, + ) + } +} + +fn prepare_claude_import( + layer: &SettingsLayer, + source_key: SourceKey, + bytes: &[u8], + expected_catalog_content_version: &str, + effective_disabled: bool, +) -> Result { + let mut catalog_handlers = Vec::::new(); + let mut handlers = Vec::new(); + let mut skipped = BTreeMap::<&'static str, u32>::new(); + let mut assets = BTreeMap::new(); + let source_config_dir = layer.path.parent().unwrap_or(Path::new(".")); + let source_stable_key = source_key.stable_key(); + let summary = visit_hook_document( + bytes, + StaticHookDocumentFormat::Json, + MAX_HANDLERS, + |candidate| { + let catalog_fact = static_hook_handler_fact(&candidate, HANDLER_RULES); + if let Some(fact) = catalog_fact.as_ref() { + catalog_handlers.push(fact.clone()); + } + if !summary_event_supported(candidate.native_event) { + increment_skip(&mut skipped, "unsupported_event"); + return catalog_fact.is_some(); + } + if summary_group_has_unknown_fields(candidate.group) { + increment_skip(&mut skipped, "unsupported_group_field"); + return catalog_fact.is_some(); + } + let existing_asset_paths = assets.keys().cloned().collect::>(); + match claude_handler( + &candidate, + &source_stable_key, + source_config_dir, + &mut assets, + ) { + Ok(handler) => handlers.push(handler), + Err(reason) => { + assets.retain(|path, _| existing_asset_paths.contains(path)); + increment_skip(&mut skipped, reason); + } + } + catalog_fact.is_some() + }, + ); + let parsed = StaticHookParseResult { + handlers: catalog_handlers, + issues: summary.issues, + all_disabled: summary.all_disabled, + inspected_handlers: summary.inspected_handlers, + }; + let content_version = redacted_parse_content_version(&parsed); + if content_version != expected_catalog_content_version { + return Err(import_error( + "claude.hook.import_catalog_stale", + "Claude Code Hook configuration changed after discovery", + )); + } + if effective_disabled { + handlers.clear(); + skipped.clear(); + skipped.insert("all_disabled", 1); + } + let mut source_diagnostics = parsed + .issues + .iter() + .copied() + .map(|issue| parse_issue_diagnostic(issue, &source_key)) + .collect::>(); + 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()), + )); + } + let prepared_source = source( + layer, + source_key, + if source_diagnostics.is_empty() { + ExternalSourceHealth::Available + } else { + ExternalSourceHealth::Degraded + }, + content_version, + source_diagnostics, + ); + PreparedExternalHookImport::new( + prepared_source, + handlers, + skipped + .into_iter() + .map(|(reason_code, count)| ExternalHookImportSkippedV1 { + reason_code: reason_code.to_string(), + count, + }) + .collect(), + assets + .into_iter() + .map(|(relative_path, bytes)| PreparedExternalHookAsset { + relative_path, + bytes, + }) + .collect(), + ) + .map_err(|error| import_error("claude.hook.import_invalid", error.to_string())) +} + +fn effective_disable_for_import( + layers: &[SettingsLayer], + selected_path: &Path, + selected_bytes: &[u8], +) -> Result { + let mut effective_disabled = false; + for layer in layers { + let selected = layer.path == selected_path; + let owned_bytes; + let bytes = if selected { + selected_bytes + } else { + match regular_file_exists(&layer.path) { + Ok(false) => continue, + Ok(true) => {} + Err(error) => { + return Err(ExternalSourceProviderError::new( + "claude.hook.import_activation_unavailable", + format!("Claude Code Hook activation metadata is unavailable: {error}"), + true, + )) + } + } + owned_bytes = + match read_bounded_file(&layer.path, MAX_SETTINGS_FILE_BYTES) { + Ok(BoundedFileRead::Content(bytes)) => bytes, + Ok(BoundedFileRead::TooLarge) => return Err(import_error( + "claude.hook.import_activation_unavailable", + "Claude Code Hook activation cannot be verified from oversized settings", + )), + Err(error) => { + return Err(ExternalSourceProviderError::new( + "claude.hook.import_activation_unavailable", + format!("Claude Code Hook activation could not be read: {error}"), + true, + )) + } + }; + &owned_bytes + }; + match parse_disable_all_hooks(bytes) { + Ok(Some(disabled)) => effective_disabled = disabled, + Ok(None) => {} + Err(()) => { + return Err(import_error( + "claude.hook.import_activation_unavailable", + "Claude Code Hook activation cannot be verified from invalid settings", + )) + } + } + if selected { + break; + } + } + Ok(effective_disabled) +} + +fn claude_handler( + candidate: &bitfun_static_hook_support::StaticHookHandlerRef<'_>, + source_stable_key: &str, + source_config_dir: &Path, + assets: &mut BTreeMap>, +) -> Result { + let handler = candidate.handler.as_object().ok_or("invalid_handler")?; + let handler_type = handler + .get("type") + .map(|value| value.as_str().ok_or("invalid_handler")) + .transpose()? + .unwrap_or("command"); + if handler_type != "command" { + return Err("unsupported_handler_type"); + } + if handler.keys().any(|field| { + !matches!( + field.as_str(), + "type" | "command" | "timeoutSec" | "statusMessage" + ) + }) { + return Err("unsupported_behavior_field"); + } + let command = required_hook_string(handler, "command")?; + let prepared_command = + prepare_static_hook_command(&command, source_config_dir, ".claude", assets) + .map_err(|error| error.skip_reason())?; + let timeout_seconds = optional_positive_hook_u64(handler, "timeoutSec")?; + let status_message = optional_hook_string(handler, "statusMessage")?; + Ok(PreparedExternalHookHandler { + stable_key: format!( + "claude-hook:{}", + short_hash( + format!( + "{source_stable_key}:{}:{}:{}", + candidate.native_event, candidate.group_index, candidate.handler_index + ) + .as_bytes() + ) + ), + event: candidate.native_event.to_string(), + matcher: importable_hook_matcher(candidate.group.get("matcher"))?, + command: prepared_command.command, + command_windows: None, + timeout_seconds, + status_message, + dependencies: prepared_command.dependencies, + }) +} + +fn summary_event_supported(event: &str) -> bool { + matches!( + event, + "PreToolUse" + | "PermissionRequest" + | "PostToolUse" + | "PreCompact" + | "PostCompact" + | "SessionStart" + | "SessionEnd" + | "UserPromptSubmit" + | "SubagentStart" + | "SubagentStop" + | "Stop" + ) +} + +fn summary_group_has_unknown_fields(group: &serde_json::Map) -> bool { + group + .keys() + .any(|field| !matches!(field.as_str(), "matcher" | "hooks")) +} + +fn increment_skip(skipped: &mut BTreeMap<&'static str, u32>, reason: &'static str) { + let count = skipped.entry(reason).or_default(); + *count = count.saturating_add(1); +} + +fn import_error(code: &'static str, message: impl Into) -> ExternalSourceProviderError { + ExternalSourceProviderError::new(code, message, false) } struct SettingsLayer { diff --git a/src/crates/adapters/claude-code-adapter/tests/hook_source.rs b/src/crates/adapters/claude-code-adapter/tests/hook_source.rs index 332459d305..41526d5fb3 100644 --- a/src/crates/adapters/claude-code-adapter/tests/hook_source.rs +++ b/src/crates/adapters/claude-code-adapter/tests/hook_source.rs @@ -15,6 +15,209 @@ fn context(workspace: &std::path::Path) -> ExternalSourceContext { } } +#[test] +fn prepares_only_the_supported_synchronous_claude_command_subset() { + 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).unwrap(); + fs::create_dir_all(user_settings.parent().unwrap().join("hooks")).unwrap(); + fs::write( + user_settings.parent().unwrap().join("hooks/check.py"), + b"print('claude')", + ) + .unwrap(); + fs::write( + &user_settings, + r#"{"hooks":{"PreToolUse":[ + {"matcher":"Bash","hooks":[{"command":"python .claude/hooks/check.py","timeoutSec":12,"statusMessage":"Checking"}]}, + {"if":"private","hooks":[{"type":"command","command":"conditional"}]}, + {"hooks":[{"type":"command","command":"later","asyncRewake":true},{"type":"http","url":"https://private"}]} + ],"Notification":[{"hooks":[{"type":"command","command":"notify"}]}]}}"#, + ) + .unwrap(); + let provider = ClaudeCodeHookProvider::new(ClaudeCodeHookProviderOptions { + user_settings_file: user_settings, + project_root_override: Some(workspace.clone()), + project_settings_enabled: true, + }); + let ctx = context(&workspace); + let catalog = provider.discover(&ctx).unwrap(); + let source = catalog.sources[0].clone(); + let prepared = provider + .prepare_import(&ctx, &source.key, &source.content_version) + .unwrap(); + + assert_eq!(prepared.handlers.len(), 1); + assert!(prepared.handlers[0] + .command + .contains("__BITFUN_MANAGED_HOOK_ROOT__/hooks/check.py")); + assert_eq!(prepared.handlers[0].timeout_seconds, Some(12)); + assert_eq!( + prepared.handlers[0].status_message.as_deref(), + Some("Checking") + ); + assert_eq!(prepared.assets.len(), 1); + assert!(prepared + .skipped + .iter() + .any(|item| item.reason_code == "unsupported_group_field" && item.count == 1)); + assert!(prepared + .skipped + .iter() + .any(|item| item.reason_code == "unsupported_behavior_field" && item.count == 1)); + assert!(prepared + .skipped + .iter() + .any(|item| item.reason_code == "unsupported_handler_type" && item.count == 1)); + assert!(prepared + .skipped + .iter() + .any(|item| item.reason_code == "unsupported_event" && item.count == 1)); +} + +#[test] +fn disable_all_hooks_yields_a_skipped_only_preview() { + 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":"private"}]}]}}"#, + ) + .unwrap(); + let provider = ClaudeCodeHookProvider::new(ClaudeCodeHookProviderOptions { + user_settings_file: user_settings, + project_root_override: Some(workspace.clone()), + project_settings_enabled: false, + }); + let ctx = context(&workspace); + let catalog = provider.discover(&ctx).unwrap(); + let source = &catalog.sources[0]; + let prepared = provider + .prepare_import(&ctx, &source.key, &source.content_version) + .unwrap(); + + assert!(prepared.handlers.is_empty()); + assert_eq!(prepared.skipped[0].reason_code, "all_disabled"); + assert_eq!(prepared.skipped[0].count, 1); +} + +#[test] +fn layered_disable_is_respected_when_preparing_a_project_source() { + 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#"{"disableAllHooks":true}"#).unwrap(); + fs::write( + workspace.join(".claude/settings.json"), + r#"{"hooks":{"PreToolUse":[{"hooks":[{"command":"private"}]}]}}"#, + ) + .unwrap(); + let provider = ClaudeCodeHookProvider::new(ClaudeCodeHookProviderOptions { + user_settings_file: user_settings, + project_root_override: Some(workspace.clone()), + project_settings_enabled: true, + }); + let ctx = context(&workspace); + let catalog = provider.discover(&ctx).unwrap(); + let source = catalog + .sources + .iter() + .find(|source| source.location_hint.replace('\\', "/") == ".claude/settings.json") + .unwrap(); + let prepared = provider + .prepare_import(&ctx, &source.key, &source.content_version) + .unwrap(); + + assert!(prepared.handlers.is_empty()); + assert_eq!(prepared.skipped[0].reason_code, "all_disabled"); +} + +#[test] +fn narrower_project_activation_cannot_enable_a_disabled_user_import() { + 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#"{"disableAllHooks":true,"hooks":{"PreToolUse":[{"hooks":[{"command":"user-command"}]}]}}"#, + ) + .unwrap(); + fs::write( + workspace.join(".claude/settings.local.json"), + r#"{"disableAllHooks":false}"#, + ) + .unwrap(); + let provider = ClaudeCodeHookProvider::new(ClaudeCodeHookProviderOptions { + user_settings_file: user_settings, + project_root_override: Some(workspace.clone()), + project_settings_enabled: true, + }); + let ctx = context(&workspace); + let catalog = provider.discover(&ctx).unwrap(); + let source = catalog + .sources + .iter() + .find(|source| { + source.scope + == bitfun_product_domains::external_sources::ExternalSourceScope::UserGlobal + }) + .unwrap(); + + let prepared = provider + .prepare_import(&ctx, &source.key, &source.content_version) + .unwrap(); + + assert!(prepared.handlers.is_empty()); + assert_eq!(prepared.skipped[0].reason_code, "all_disabled"); +} + +#[test] +fn skipped_handlers_do_not_leave_assets_in_another_valid_handler_plan() { + let root = tempdir().unwrap(); + let user_settings = root.path().join("home/.claude/settings.json"); + let workspace = root.path().join("workspace"); + let hooks = user_settings.parent().unwrap().join("hooks"); + fs::create_dir_all(&hooks).unwrap(); + fs::create_dir_all(&workspace).unwrap(); + fs::write(hooks.join("valid.py"), b"print('valid')").unwrap(); + fs::write(hooks.join("skipped.py"), b"print('must not copy')").unwrap(); + fs::write( + &user_settings, + r#"{"hooks":{"PreToolUse":[{"hooks":[ + {"command":"python .claude/hooks/valid.py"}, + {"command":"python .claude/hooks/skipped.py","timeoutSec":0} + ]}]}}"#, + ) + .unwrap(); + let provider = ClaudeCodeHookProvider::new(ClaudeCodeHookProviderOptions { + user_settings_file: user_settings, + project_root_override: Some(workspace.clone()), + project_settings_enabled: false, + }); + let ctx = context(&workspace); + let catalog = provider.discover(&ctx).unwrap(); + let source = &catalog.sources[0]; + + let prepared = provider + .prepare_import(&ctx, &source.key, &source.content_version) + .unwrap(); + + assert_eq!(prepared.handlers.len(), 1); + assert_eq!(prepared.assets.len(), 1); + assert_eq!( + prepared.assets[0].relative_path, + std::path::PathBuf::from("hooks/valid.py") + ); +} + #[test] fn discovers_user_project_and_local_settings_with_native_handler_kinds() { let root = tempdir().unwrap(); diff --git a/src/crates/adapters/codex-adapter/src/hook_source.rs b/src/crates/adapters/codex-adapter/src/hook_source.rs index 84343b8a42..583596be92 100644 --- a/src/crates/adapters/codex-adapter/src/hook_source.rs +++ b/src/crates/adapters/codex-adapter/src/hook_source.rs @@ -5,13 +5,19 @@ use bitfun_product_domains::external_hook_catalog::{ ExternalHookSourceKind, ExternalHookSourceProvider, }; use bitfun_product_domains::external_hook_contributions::ExternalHookPoint; +use bitfun_product_domains::external_hook_import::{ + ExternalHookImportSkippedV1, PreparedExternalHookAsset, PreparedExternalHookHandler, + PreparedExternalHookImport, +}; 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, + bounded_project_ancestors, importable_hook_matcher, optional_hook_string, + optional_positive_hook_u64, parse_hook_document, prepare_static_hook_command, + read_bounded_file, redacted_parse_content_version, regular_file_exists, required_hook_string, + static_hook_handler_fact, visit_hook_document, BoundedFileRead, StaticHookDocumentFormat, StaticHookHandlerFact, StaticHookHandlerRule, StaticHookParseIssue, StaticHookParseResult, }; use serde_json::Value; @@ -183,6 +189,32 @@ impl CodexHookProvider { } layers } + + fn resolved_layers( + &self, + context: &ExternalSourceContext, + ) -> Result { + 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), + }; + Ok(ResolvedCodexLayers { + layers: self.layers(context, &project_root_markers), + user_config_path, + user_config, + project_root_markers_invalid, + }) + } } impl Default for CodexHookProvider { @@ -212,21 +244,11 @@ impl ExternalHookSourceProvider for CodexHookProvider { 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 resolved = self.resolved_layers(context)?; + let user_config_path = resolved.user_config_path; + let user_config = resolved.user_config; + let project_root_markers_invalid = resolved.project_root_markers_invalid; + let layers = resolved.layers; let paths = layers .iter() .map(|layer| layer.path.clone()) @@ -299,6 +321,60 @@ impl ExternalHookSourceProvider for CodexHookProvider { })?; Ok(snapshot) } + + fn prepare_import( + &self, + context: &ExternalSourceContext, + requested_source: &SourceKey, + expected_catalog_content_version: &str, + ) -> Result { + if requested_source.provider_id.as_str() != PROVIDER_ID { + return Err(import_error( + "codex.hook.import_provider_mismatch", + "The requested Hook source belongs to another provider", + )); + } + let resolved = self.resolved_layers(context)?; + let Some(layer) = resolved + .layers + .iter() + .find(|layer| layer.source_id == requested_source.source_id.as_str()) + else { + return Err(import_error( + "codex.hook.import_source_missing", + "The requested Codex Hook source is no longer available", + )); + }; + let bytes = match read_bounded_file(&layer.path, MAX_CONFIG_FILE_BYTES) { + Ok(BoundedFileRead::Content(bytes)) => bytes, + Ok(BoundedFileRead::TooLarge) => { + return Err(import_error( + "codex.hook.import_source_too_large", + "Codex Hook configuration exceeds the 1 MiB import limit", + )) + } + Err(error) => { + return Err(ExternalSourceProviderError::new( + "codex.hook.import_source_unreadable", + format!("Codex Hook configuration could not be read: {error}"), + true, + )) + } + }; + prepare_codex_import( + layer, + requested_source.clone(), + &bytes, + expected_catalog_content_version, + ) + } +} + +struct ResolvedCodexLayers { + layers: Vec, + user_config_path: PathBuf, + user_config: Option, + project_root_markers_invalid: bool, } #[derive(Clone, Copy)] @@ -312,6 +388,181 @@ enum LoadedConfigFile { TooLarge, } +fn prepare_codex_import( + layer: &ConfigLayer, + source_key: SourceKey, + bytes: &[u8], + expected_catalog_content_version: &str, +) -> Result { + let source_stable_key = source_key.stable_key(); + let mut catalog_handlers = Vec::::new(); + let mut handlers = Vec::new(); + let mut skipped = BTreeMap::<&'static str, u32>::new(); + let mut assets = BTreeMap::new(); + let source_config_dir = layer.path.parent().unwrap_or(Path::new(".")); + let format = match layer.format { + ConfigFormat::Json => StaticHookDocumentFormat::Json, + ConfigFormat::Toml => StaticHookDocumentFormat::Toml, + }; + let summary = visit_hook_document(bytes, format, MAX_HANDLERS, |candidate| { + let catalog_fact = static_hook_handler_fact(&candidate, HANDLER_RULES); + if let Some(fact) = catalog_fact.as_ref() { + catalog_handlers.push(fact.clone()); + } + if !CODEX_HOOK_EVENTS.contains(&candidate.native_event) { + increment_skip(&mut skipped, "unsupported_event"); + return catalog_fact.is_some(); + } + if candidate + .group + .keys() + .any(|field| !matches!(field.as_str(), "matcher" | "hooks")) + { + increment_skip(&mut skipped, "unsupported_group_field"); + return catalog_fact.is_some(); + } + let existing_asset_paths = assets.keys().cloned().collect::>(); + match codex_handler( + &candidate, + &source_stable_key, + source_config_dir, + &mut assets, + ) { + Ok(handler) => handlers.push(handler), + Err(reason) => { + assets.retain(|path, _| existing_asset_paths.contains(path)); + increment_skip(&mut skipped, reason); + } + } + catalog_fact.is_some() + }); + let mut parsed = StaticHookParseResult { + handlers: catalog_handlers, + issues: summary.issues, + all_disabled: summary.all_disabled, + inspected_handlers: summary.inspected_handlers, + }; + validate_codex_document(bytes, layer.format, &mut parsed); + let content_version = codex_redacted_content_version(&parsed); + if content_version != expected_catalog_content_version { + return Err(import_error( + "codex.hook.import_catalog_stale", + "Codex Hook configuration changed after discovery", + )); + } + let source_diagnostics = parsed + .issues + .iter() + .copied() + .map(|issue| parse_issue_diagnostic(issue, &source_key)) + .collect::>(); + let prepared_source = source( + layer, + source_key, + if source_diagnostics.is_empty() { + ExternalSourceHealth::Available + } else { + ExternalSourceHealth::Degraded + }, + content_version, + source_diagnostics, + ); + PreparedExternalHookImport::new( + prepared_source, + handlers, + skipped + .into_iter() + .map(|(reason_code, count)| ExternalHookImportSkippedV1 { + reason_code: reason_code.to_string(), + count, + }) + .collect(), + assets + .into_iter() + .map(|(relative_path, bytes)| PreparedExternalHookAsset { + relative_path, + bytes, + }) + .collect(), + ) + .map_err(|error| import_error("codex.hook.import_invalid", error.to_string())) +} + +fn codex_handler( + candidate: &bitfun_static_hook_support::StaticHookHandlerRef<'_>, + source_stable_key: &str, + source_config_dir: &Path, + assets: &mut BTreeMap>, +) -> Result { + let handler = candidate.handler.as_object().ok_or("invalid_handler")?; + match handler.get("type").and_then(Value::as_str) { + Some("command") => {} + Some("prompt" | "agent") => return Err("unsupported_handler_type"), + _ => return Err("invalid_handler"), + } + if handler.keys().any(|field| { + !matches!( + field.as_str(), + "type" | "command" | "commandWindows" | "command_windows" | "timeout" | "statusMessage" + ) + }) { + return Err("unsupported_behavior_field"); + } + let command = required_hook_string(handler, "command")?; + let command_windows = match ( + optional_hook_string(handler, "commandWindows")?, + optional_hook_string(handler, "command_windows")?, + ) { + (Some(left), Some(right)) if left != right => return Err("conflicting_windows_command"), + (Some(value), _) | (_, Some(value)) => Some(value), + (None, None) => None, + }; + let prepared_command = + prepare_static_hook_command(&command, source_config_dir, ".codex", assets) + .map_err(|error| error.skip_reason())?; + let prepared_windows = command_windows + .as_deref() + .map(|command| prepare_static_hook_command(command, source_config_dir, ".codex", assets)) + .transpose() + .map_err(|error| error.skip_reason())?; + let mut dependencies = prepared_command.dependencies; + if let Some(prepared_windows) = &prepared_windows { + for dependency in &prepared_windows.dependencies { + if !dependencies.contains(dependency) { + dependencies.push(dependency.clone()); + } + } + } + Ok(PreparedExternalHookHandler { + stable_key: format!( + "codex-hook:{}", + short_hash( + format!( + "{source_stable_key}:{}:{}:{}", + candidate.native_event, candidate.group_index, candidate.handler_index + ) + .as_bytes() + ) + ), + event: candidate.native_event.to_string(), + matcher: importable_hook_matcher(candidate.group.get("matcher"))?, + command: prepared_command.command, + command_windows: prepared_windows.map(|prepared| prepared.command), + timeout_seconds: optional_positive_hook_u64(handler, "timeout")?, + status_message: optional_hook_string(handler, "statusMessage")?, + dependencies, + }) +} + +fn increment_skip(skipped: &mut BTreeMap<&'static str, u32>, reason: &'static str) { + let count = skipped.entry(reason).or_default(); + *count = count.saturating_add(1); +} + +fn import_error(code: &'static str, message: impl Into) -> ExternalSourceProviderError { + ExternalSourceProviderError::new(code, message, false) +} + fn load_config_file(path: &Path) -> Result, ExternalSourceProviderError> { match regular_file_exists(path) { Ok(false) => Ok(None), diff --git a/src/crates/adapters/codex-adapter/tests/hook_source.rs b/src/crates/adapters/codex-adapter/tests/hook_source.rs index 0fd013931a..168af9292e 100644 --- a/src/crates/adapters/codex-adapter/tests/hook_source.rs +++ b/src/crates/adapters/codex-adapter/tests/hook_source.rs @@ -15,6 +15,90 @@ fn context(workspace: &std::path::Path) -> ExternalSourceContext { } } +#[test] +fn prepares_only_the_supported_synchronous_codex_command_subset() { + 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::create_dir_all(codex_home.join("hooks")).unwrap(); + fs::write(codex_home.join("hooks/check.py"), b"print('codex')").unwrap(); + let hooks_path = codex_home.join("hooks.json"); + fs::write( + &hooks_path, + r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[ + {"type":"command","command":"python .codex/hooks/check.py --private","commandWindows":"python .codex/hooks/check.py --private","timeout":17,"statusMessage":"Checking"}, + {"type":"command","command":"later","async":true}, + {"type":"prompt","prompt":"private"} + ]}],"UnsupportedEvent":[{"hooks":[{"type":"command","command":"never"}]}]}}"#, + ) + .unwrap(); + let provider = CodexHookProvider::new(CodexHookProviderOptions { + codex_home, + project_root_override: Some(workspace.clone()), + project_hooks_root_override: None, + project_hooks_enabled: true, + }); + let ctx = context(&workspace); + let first_catalog = provider.discover(&ctx).unwrap(); + let source = first_catalog.sources[0].clone(); + let first = provider + .prepare_import(&ctx, &source.key, &source.content_version) + .unwrap(); + + assert_eq!(first.handlers.len(), 1); + assert_eq!(first.handlers[0].event, "PreToolUse"); + assert_eq!(first.handlers[0].matcher.as_deref(), Some("Bash")); + assert!(first.handlers[0] + .command + .contains("__BITFUN_MANAGED_HOOK_ROOT__/hooks/check.py")); + assert!(first.handlers[0] + .command_windows + .as_deref() + .unwrap() + .contains("__BITFUN_MANAGED_HOOK_ROOT__/hooks/check.py")); + assert_eq!(first.handlers[0].timeout_seconds, Some(17)); + assert_eq!( + first.handlers[0].status_message.as_deref(), + Some("Checking") + ); + assert_eq!(first.assets.len(), 1); + assert_eq!( + first.assets[0].relative_path, + std::path::Path::new("hooks/check.py") + ); + assert!(first + .skipped + .iter() + .any(|item| item.reason_code == "unsupported_behavior_field" && item.count == 1)); + assert!(first + .skipped + .iter() + .any(|item| item.reason_code == "unsupported_handler_type" && item.count == 1)); + assert!(first + .skipped + .iter() + .any(|item| item.reason_code == "unsupported_event" && item.count == 1)); + + fs::write( + &hooks_path, + r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[ + {"type":"command","command":"python .codex/hooks/check.py --changed","commandWindows":"python .codex/hooks/check.py --private","timeout":17,"statusMessage":"Checking"}, + {"type":"command","command":"later","async":true}, + {"type":"prompt","prompt":"private"} + ]}],"UnsupportedEvent":[{"hooks":[{"type":"command","command":"never"}]}]}}"#, + ) + .unwrap(); + let second_catalog = provider.discover(&ctx).unwrap(); + let second_source = &second_catalog.sources[0]; + assert_eq!(source.content_version, second_source.content_version); + let second = provider + .prepare_import(&ctx, &second_source.key, &second_source.content_version) + .unwrap(); + assert_ne!(first.behavior_version, second.behavior_version); +} + #[test] fn discovers_hooks_json_and_inline_toml_without_exposing_handler_content() { let root = tempdir().unwrap(); diff --git a/src/crates/adapters/static-hook-support/src/lib.rs b/src/crates/adapters/static-hook-support/src/lib.rs index 5a733605c5..2575f8a5b5 100644 --- a/src/crates/adapters/static-hook-support/src/lib.rs +++ b/src/crates/adapters/static-hook-support/src/lib.rs @@ -3,10 +3,16 @@ use bitfun_product_domains::external_hook_catalog::{ ExternalHookHandlerKind, ExternalHookMatcherSummary, }; +use bitfun_product_domains::external_hook_import::{ + ExternalHookImportDependencyV1, MANAGED_HOOK_ROOT_PLACEHOLDER, MAX_EXTERNAL_HOOK_IMPORT_ASSETS, + MAX_EXTERNAL_HOOK_IMPORT_ASSET_BYTES, MAX_EXTERNAL_HOOK_IMPORT_ASSET_DEPTH, + MAX_EXTERNAL_HOOK_IMPORT_TOTAL_ASSET_BYTES, +}; use serde_json::Value; use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; use std::io::Read; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; const MAX_MATCHER_BYTES: usize = 512; const MAX_EVENT_NAME_BYTES: usize = 160; @@ -203,6 +209,270 @@ pub fn resolve_bounded_regular_file( Ok(canonical_path) } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PreparedStaticHookCommand { + pub command: String, + pub dependencies: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StaticHookAssetError { + DynamicPath, + InvalidPath, + MissingOrLinked, + Unreadable, + BudgetExceeded, +} + +impl StaticHookAssetError { + pub const fn skip_reason(self) -> &'static str { + match self { + Self::DynamicPath => "dynamic_source_path", + Self::InvalidPath => "invalid_asset_path", + Self::MissingOrLinked => "asset_missing_or_linked", + Self::Unreadable => "asset_unreadable", + Self::BudgetExceeded => "asset_budget_exceeded", + } + } +} + +pub fn importable_hook_matcher(value: Option<&Value>) -> Result, &'static str> { + match value { + None | Some(Value::Null) => Ok(None), + Some(Value::String(value)) if value.is_empty() => Ok(None), + Some(Value::String(value)) + if value.len() <= MAX_MATCHER_BYTES && !value.chars().any(char::is_control) => + { + Ok(Some(value.clone())) + } + Some(_) => Err("invalid_matcher"), + } +} + +pub fn required_hook_string( + object: &serde_json::Map, + field: &str, +) -> Result { + object + .get(field) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .ok_or("invalid_handler") +} + +pub fn optional_hook_string( + object: &serde_json::Map, + field: &str, +) -> Result, &'static str> { + match object.get(field) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(value)) => Ok(Some(value.clone())), + Some(_) => Err("invalid_handler"), + } +} + +pub fn optional_positive_hook_u64( + object: &serde_json::Map, + field: &str, +) -> Result, &'static str> { + match object.get(field) { + None | Some(Value::Null) => Ok(None), + Some(value) => value + .as_u64() + .filter(|value| *value > 0) + .map(Some) + .ok_or("invalid_handler"), + } +} + +/// Rewrites only simple shell tokens that point below `/hooks`. +/// Arbitrary shell syntax is intentionally left untouched; source-root tokens +/// with expansion or glob syntax fail closed instead of being guessed. +pub fn prepare_static_hook_command( + command: &str, + source_config_dir: &Path, + source_dir_name: &str, + assets: &mut BTreeMap>, +) -> Result { + let normalized_source = source_dir_name.replace('\\', "/"); + let prefix = format!("{normalized_source}/hooks/"); + let dot_prefix = format!("./{prefix}"); + let mut replacements = Vec::<(usize, usize, String)>::new(); + let mut dependencies = Vec::new(); + let mut dependency_keys = BTreeSet::new(); + let mut pending_assets = BTreeMap::>::new(); + let mut recognized_managed_path = false; + + for token in simple_command_tokens(command) { + let normalized = token.value.replace('\\', "/"); + let managed_suffix = normalized + .strip_prefix(&dot_prefix) + .or_else(|| normalized.strip_prefix(&prefix)); + if let Some(suffix) = managed_suffix { + recognized_managed_path = true; + if suffix.is_empty() + || suffix + .chars() + .any(|value| matches!(value, '$' | '`' | '*' | '?' | '[' | ']' | '{' | '}')) + { + return Err(StaticHookAssetError::DynamicPath); + } + let suffix_path = PathBuf::from(suffix); + if suffix_path.is_absolute() + || suffix_path + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + return Err(StaticHookAssetError::InvalidPath); + } + let relative_path = PathBuf::from("hooks").join(&suffix_path); + if relative_path.components().count() > MAX_EXTERNAL_HOOK_IMPORT_ASSET_DEPTH { + return Err(StaticHookAssetError::BudgetExceeded); + } + if !assets.contains_key(&relative_path) && !pending_assets.contains_key(&relative_path) + { + let source_path = source_config_dir.join(&relative_path); + let resolved = resolve_regular_file_without_links(&source_path, source_config_dir)?; + let bytes = match read_bounded_file(&resolved, MAX_EXTERNAL_HOOK_IMPORT_ASSET_BYTES) + { + Ok(BoundedFileRead::Content(bytes)) => bytes, + Ok(BoundedFileRead::TooLarge) => { + return Err(StaticHookAssetError::BudgetExceeded) + } + Err(_) => return Err(StaticHookAssetError::Unreadable), + }; + pending_assets.insert(relative_path.clone(), bytes); + } + let relative_text = relative_path.to_string_lossy().replace('\\', "/"); + let dependency = ExternalHookImportDependencyV1::Managed { + relative_path: relative_text.clone(), + }; + if dependency_keys.insert(format!("managed:{relative_text}")) { + dependencies.push(dependency); + } + replacements.push(( + token.start, + token.end, + format!("\"{MANAGED_HOOK_ROOT_PLACEHOLDER}/{relative_text}\""), + )); + } else if Path::new(&token.value).is_absolute() { + let location = token.value.to_string(); + if dependency_keys.insert(format!("external:{location}")) { + dependencies.push(ExternalHookImportDependencyV1::External { location }); + } + } + } + if !recognized_managed_path && command.replace('\\', "/").contains(&prefix) { + return Err(StaticHookAssetError::DynamicPath); + } + + let file_count = assets.len().saturating_add(pending_assets.len()); + let byte_count = assets + .values() + .chain(pending_assets.values()) + .try_fold(0usize, |total, bytes| total.checked_add(bytes.len())) + .ok_or(StaticHookAssetError::BudgetExceeded)?; + if file_count > MAX_EXTERNAL_HOOK_IMPORT_ASSETS + || byte_count > MAX_EXTERNAL_HOOK_IMPORT_TOTAL_ASSET_BYTES + { + return Err(StaticHookAssetError::BudgetExceeded); + } + + let mut rewritten = command.to_string(); + for (start, end, replacement) in replacements.into_iter().rev() { + rewritten.replace_range(start..end, &replacement); + } + assets.extend(pending_assets); + Ok(PreparedStaticHookCommand { + command: rewritten, + dependencies, + }) +} + +struct CommandToken<'a> { + start: usize, + end: usize, + value: &'a str, +} + +fn simple_command_tokens(command: &str) -> Vec> { + let mut tokens = Vec::new(); + let bytes = command.as_bytes(); + let mut cursor = 0usize; + while cursor < bytes.len() { + while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() { + cursor += 1; + } + if cursor == bytes.len() { + break; + } + let start = cursor; + let quote = matches!(bytes[cursor], b'\'' | b'\"').then_some(bytes[cursor]); + if quote.is_some() { + cursor += 1; + } + let value_start = cursor; + while cursor < bytes.len() + && match quote { + Some(quote) => bytes[cursor] != quote, + None => !bytes[cursor].is_ascii_whitespace(), + } + { + cursor += 1; + } + let value_end = cursor; + if quote.is_some() && cursor < bytes.len() { + cursor += 1; + } + tokens.push(CommandToken { + start, + end: cursor, + value: &command[value_start..value_end], + }); + } + tokens +} + +fn resolve_regular_file_without_links( + path: &Path, + allowed_root: &Path, +) -> Result { + let relative = path + .strip_prefix(allowed_root) + .map_err(|_| StaticHookAssetError::InvalidPath)?; + let mut current = allowed_root.to_path_buf(); + for component in relative.components() { + let Component::Normal(component) = component else { + return Err(StaticHookAssetError::InvalidPath); + }; + current.push(component); + let metadata = std::fs::symlink_metadata(¤t) + .map_err(|_| StaticHookAssetError::MissingOrLinked)?; + if metadata.file_type().is_symlink() || metadata_is_reparse_point(&metadata) { + return Err(StaticHookAssetError::MissingOrLinked); + } + } + let metadata = + std::fs::metadata(¤t).map_err(|_| StaticHookAssetError::MissingOrLinked)?; + if !metadata.is_file() { + return Err(StaticHookAssetError::MissingOrLinked); + } + Ok(current) +} + +#[cfg(windows)] +fn metadata_is_reparse_point(metadata: &std::fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt; + metadata.file_attributes() & 0x400 != 0 +} + +#[cfg(not(windows))] +fn metadata_is_reparse_point(_metadata: &std::fs::Metadata) -> bool { + false +} + /// Produces a useful executable label without exposing an absolute path or a /// shell-like command string. Runtime preparation retains the original value. pub fn redacted_executable_preview(command: &str) -> String { @@ -305,6 +575,25 @@ pub struct StaticHookParseResult { pub inspected_handlers: usize, } +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct StaticHookVisitSummary { + pub issues: Vec, + pub all_disabled: bool, + pub inspected_handlers: usize, +} + +/// Borrowed structural facts for one handler during a bounded document walk. +/// +/// This type intentionally has no `Debug` implementation: `group` and +/// `handler` may contain commands, environment values, or credentials. +pub struct StaticHookHandlerRef<'a> { + pub native_event: &'a str, + pub group: &'a serde_json::Map, + pub handler: &'a Value, + pub group_index: usize, + pub handler_index: 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. @@ -340,14 +629,15 @@ pub fn redacted_parse_content_version(result: &StaticHookParseResult) -> String 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( +/// Walks Hook structure once and exposes handler bodies only to the supplied +/// callback. Returning `false` marks that handler invalid; no borrowed value is +/// retained in the returned summary. +pub fn visit_hook_document( bytes: &[u8], format: StaticHookDocumentFormat, - rules: &[StaticHookHandlerRule], max_handlers: usize, -) -> StaticHookParseResult { + mut visitor: impl FnMut(StaticHookHandlerRef<'_>) -> bool, +) -> StaticHookVisitSummary { let parsed = match format { StaticHookDocumentFormat::Json => serde_json::from_slice::(bytes).ok(), StaticHookDocumentFormat::Toml => std::str::from_utf8(bytes) @@ -356,9 +646,9 @@ pub fn parse_hook_document( .and_then(|value| serde_json::to_value(value).ok()), }; let Some(Value::Object(root)) = parsed else { - return StaticHookParseResult { + return StaticHookVisitSummary { issues: vec![StaticHookParseIssue::DocumentInvalid], - ..StaticHookParseResult::default() + ..StaticHookVisitSummary::default() }; }; @@ -368,9 +658,9 @@ pub fn parse_hook_document( .get("disableAllHooks") .and_then(Value::as_bool) .unwrap_or(false); - let mut result = StaticHookParseResult { + let mut result = StaticHookVisitSummary { all_disabled, - ..StaticHookParseResult::default() + ..StaticHookVisitSummary::default() }; let Some(Value::Object(events)) = root.get("hooks") else { return result; @@ -387,47 +677,85 @@ pub fn parse_hook_document( || native_event.len() > MAX_EVENT_NAME_BYTES || native_event.chars().any(char::is_control) { - record_issue(&mut result, StaticHookParseIssue::EventNameInvalid); + record_visit_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); + record_visit_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); + record_visit_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); + record_visit_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); + record_visit_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, + if !visitor(StaticHookHandlerRef { + native_event: &native_event, + group, + handler, group_index, handler_index, - }); + }) { + record_visit_issue(&mut result, StaticHookParseIssue::HandlerInvalid); + } } } } result } -fn record_issue(result: &mut StaticHookParseResult, issue: StaticHookParseIssue) { +/// 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 mut handlers = Vec::new(); + let summary = visit_hook_document(bytes, format, max_handlers, |candidate| { + let Some(fact) = static_hook_handler_fact(&candidate, rules) else { + return false; + }; + handlers.push(fact); + true + }); + StaticHookParseResult { + handlers, + issues: summary.issues, + all_disabled: summary.all_disabled, + inspected_handlers: summary.inspected_handlers, + } +} + +/// Converts one borrowed visit item to the same redacted fact used by the +/// compatibility parser. Import adapters use this to guard the public catalog +/// version without walking the document a second time. +pub fn static_hook_handler_fact( + candidate: &StaticHookHandlerRef<'_>, + rules: &[StaticHookHandlerRule], +) -> Option { + let handler_kind = parse_handler_kind(candidate.handler, rules)?; + Some(StaticHookHandlerFact { + native_event: candidate.native_event.to_string(), + matcher: matcher_summary(candidate.group.get("matcher")), + handler_kind, + group_index: candidate.group_index, + handler_index: candidate.handler_index, + }) +} + +fn record_visit_issue(result: &mut StaticHookVisitSummary, issue: StaticHookParseIssue) { if !result.issues.contains(&issue) { result.issues.push(issue); } diff --git a/src/crates/adapters/static-hook-support/tests/parser.rs b/src/crates/adapters/static-hook-support/tests/parser.rs index 54970aa35f..bbde3c1bc5 100644 --- a/src/crates/adapters/static-hook-support/tests/parser.rs +++ b/src/crates/adapters/static-hook-support/tests/parser.rs @@ -2,9 +2,11 @@ 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, + parse_hook_document, prepare_static_hook_command, read_bounded_file, + redacted_parse_content_version, regular_file_exists, visit_hook_document, BoundedFileRead, + StaticHookAssetError, StaticHookDocumentFormat, StaticHookHandlerRule, StaticHookParseIssue, }; +use std::collections::BTreeMap; use std::fs; const RULES: &[StaticHookHandlerRule] = &[ @@ -12,6 +14,41 @@ const RULES: &[StaticHookHandlerRule] = &[ StaticHookHandlerRule::new("prompt", ExternalHookHandlerKind::Prompt, &[]), ]; +#[test] +fn one_document_walk_exposes_borrowed_handlers_without_retaining_bodies() { + let mut commands = Vec::new(); + let summary = visit_hook_document( + br#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"private-token"},{}]}]}}"#, + StaticHookDocumentFormat::Json, + 16, + |handler| { + let Some(command) = handler + .handler + .as_object() + .and_then(|value| value.get("command")) + .and_then(serde_json::Value::as_str) + else { + return false; + }; + commands.push(( + handler.native_event.to_string(), + handler.group_index, + handler.handler_index, + command.to_string(), + )); + true + }, + ); + + assert_eq!( + commands, + vec![("PreToolUse".to_string(), 0, 0, "private-token".to_string())] + ); + assert_eq!(summary.inspected_handlers, 2); + assert_eq!(summary.issues, vec![StaticHookParseIssue::HandlerInvalid]); + assert!(!format!("{summary:?}").contains("private-token")); +} + #[test] fn parses_json_to_redacted_handler_facts() { let parsed = parse_hook_document( @@ -185,3 +222,59 @@ fn repeated_parse_failures_are_aggregated_by_issue_kind() { 1 ); } + +#[test] +fn static_hook_command_copies_only_referenced_assets_and_marks_external_paths() { + let temp = tempfile::tempdir().unwrap(); + let config_dir = temp.path().join(".claude"); + fs::create_dir_all(config_dir.join("hooks")).unwrap(); + fs::write(config_dir.join("hooks/check.py"), b"print('checked')").unwrap(); + let external = temp.path().join("external-tool"); + let external_text = external.to_string_lossy().replace('\\', "/"); + let mut assets = BTreeMap::new(); + + let prepared = prepare_static_hook_command( + &format!("python .claude/hooks/check.py {external_text}"), + &config_dir, + ".claude", + &mut assets, + ) + .unwrap(); + + assert!(prepared + .command + .contains("__BITFUN_MANAGED_HOOK_ROOT__/hooks/check.py")); + assert_eq!( + assets.get(std::path::Path::new("hooks/check.py")).unwrap(), + b"print('checked')" + ); + assert!(prepared.dependencies.iter().any(|dependency| matches!( + dependency, + bitfun_product_domains::external_hook_import::ExternalHookImportDependencyV1::Managed { relative_path } + if relative_path == "hooks/check.py" + ))); + assert!(prepared.dependencies.iter().any(|dependency| matches!( + dependency, + bitfun_product_domains::external_hook_import::ExternalHookImportDependencyV1::External { location } + if location.replace('\\', "/") == external_text + ))); +} + +#[test] +fn dynamic_source_hook_paths_fail_closed_without_mutating_assets() { + let temp = tempfile::tempdir().unwrap(); + let config_dir = temp.path().join(".claude"); + fs::create_dir_all(config_dir.join("hooks")).unwrap(); + let mut assets = BTreeMap::new(); + + let error = prepare_static_hook_command( + "python .claude/hooks/$SCRIPT.py", + &config_dir, + ".claude", + &mut assets, + ) + .unwrap_err(); + + assert_eq!(error, StaticHookAssetError::DynamicPath); + assert!(assets.is_empty()); +} diff --git a/src/crates/assembly/core/src/external_hook_import.rs b/src/crates/assembly/core/src/external_hook_import.rs new file mode 100644 index 0000000000..646e1c87ee --- /dev/null +++ b/src/crates/assembly/core/src/external_hook_import.rs @@ -0,0 +1,742 @@ +//! Explicit local plan/apply lifecycle for imported command Hooks. + +use crate::external_hooks::service_for; +use crate::external_sources::normalize_workspace_root; +use crate::infrastructure::{try_get_path_manager_arc, PathManager}; +use bitfun_product_domains::external_hook_import::{ + ExternalHookImportApplyOutcomeV1, ExternalHookImportApplyRequestV1, + ExternalHookImportApplyResultV1, ExternalHookImportDispositionV1, ExternalHookImportHandlerV1, + ExternalHookImportMutationRequestV1, ExternalHookImportMutationV1, ExternalHookImportPlanV1, + ExternalHookImportSkippedV1, ExternalHookImportSnapshotV1, ImportedHookSourceSnapshotV1, + ImportedHookSourceStateV1, PreparedExternalHookImport, EXTERNAL_HOOK_IMPORT_SCHEMA_V1, +}; +use bitfun_product_domains::external_sources::{ + ExternalSourceAssetKind, ExternalSourceDiagnostic, ExternalSourceOperationError, + ExternalSourceOperationErrorCode, ExternalSourceOperationResult, ExternalSourceProviderError, + ExternalSourceScope, SourceKey, +}; +use bitfun_services_integrations::hook_import::{ + HookImportApply, HookImportRecord, HookImportStore, HookImportStoreError, + HookImportStoreSnapshot, HookImportWrite, +}; +use futures::future::join_all; +use serde_json::{Map, Value}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, OnceLock}; + +const MAX_CACHED_IMPORT_STORES: usize = 64; + +struct CachedImportStore { + store: Arc, + last_used: u64, +} + +struct StoreSet { + user: Arc, + workspace: Option>, + workspace_identity: String, +} + +struct PreparedPlan { + plan: ExternalHookImportPlanV1, + prepared: PreparedExternalHookImport, + hooks_json: Option>, + store: Arc, + target_generation: u64, +} + +fn store_cache() -> &'static tokio::sync::Mutex> { + static CACHE: OnceLock>> = + OnceLock::new(); + CACHE.get_or_init(|| tokio::sync::Mutex::new(BTreeMap::new())) +} + +fn next_store_tick() -> u64 { + static TICK: AtomicU64 = AtomicU64::new(1); + TICK.fetch_add(1, Ordering::Relaxed) +} + +async fn store_for( + root: PathBuf, + scope: ExternalSourceScope, +) -> ExternalSourceOperationResult> { + { + let mut cache = store_cache().lock().await; + if let Some(cached) = cache.get_mut(&root) { + cached.last_used = next_store_tick(); + return Ok(Arc::clone(&cached.store)); + } + } + let store = Arc::new( + HookImportStore::open(root.clone(), scope) + .await + .map_err(map_store_error)?, + ); + let mut cache = store_cache().lock().await; + if let Some(cached) = cache.get_mut(&root) { + cached.last_used = next_store_tick(); + return Ok(Arc::clone(&cached.store)); + } + if cache.len() >= MAX_CACHED_IMPORT_STORES { + if let Some(oldest) = cache + .iter() + .min_by_key(|(_, cached)| cached.last_used) + .map(|(root, _)| root.clone()) + { + cache.remove(&oldest); + } + } + cache.insert( + root, + CachedImportStore { + store: Arc::clone(&store), + last_used: next_store_tick(), + }, + ); + Ok(store) +} + +async fn stores_for(workspace: Option<&Path>) -> ExternalSourceOperationResult { + let workspace = normalize_workspace_root(workspace).map_err(|error| { + ExternalSourceOperationError::new( + ExternalSourceOperationErrorCode::InvalidRequest, + error, + false, + ) + })?; + let path_manager = try_get_path_manager_arc().map_err(|error| { + ExternalSourceOperationError::new( + ExternalSourceOperationErrorCode::Internal, + error.to_string(), + false, + ) + })?; + let user = store_for( + path_manager.user_data_dir().join("hook-imports"), + ExternalSourceScope::UserGlobal, + ) + .await?; + let workspace_store = match workspace.as_deref() { + Some(workspace) => Some( + store_for( + path_manager + .project_runtime_root(workspace) + .join("hook-imports") + .join(PathManager::native_path_digest(workspace)), + ExternalSourceScope::Project, + ) + .await?, + ), + None => None, + }; + Ok(StoreSet { + user, + workspace: workspace_store, + workspace_identity: workspace + .map(|path| path.to_string_lossy().replace('\\', "/")) + .unwrap_or_else(|| "none".to_string()), + }) +} + +pub async fn external_hook_import_snapshot( + workspace: Option<&Path>, + refresh_updates: bool, +) -> ExternalSourceOperationResult { + let catalog_service = service_for(workspace).await?; + let catalog = catalog_service.snapshot_or_refresh(refresh_updates).await?; + let stores = stores_for(workspace).await?; + let user = stores.user.snapshot().await.map_err(map_store_error)?; + let workspace_snapshot = match &stores.workspace { + Some(store) => Some(store.snapshot().await.map_err(map_store_error)?), + None => None, + }; + let revision = combined_revision( + &stores.workspace_identity, + &user, + workspace_snapshot.as_ref(), + ); + let mut diagnostics = Vec::new(); + append_corrupt_diagnostic(&mut diagnostics, ExternalSourceScope::UserGlobal, &user); + if let Some(snapshot) = &workspace_snapshot { + append_corrupt_diagnostic(&mut diagnostics, ExternalSourceScope::Project, snapshot); + } + let mut imports = join_all( + user.imports + .iter() + .chain( + workspace_snapshot + .iter() + .flat_map(|snapshot| &snapshot.imports), + ) + .map(|record| async { + let state = + import_state(record, &catalog.sources, &catalog_service, refresh_updates).await; + ImportedHookSourceSnapshotV1 { + import_id: record.import_id.clone(), + source: record.source.clone(), + enabled: record.enabled, + behavior_version: record.behavior_version.clone(), + state, + } + }), + ) + .await; + imports.sort_by(|left, right| left.import_id.cmp(&right.import_id)); + Ok(ExternalHookImportSnapshotV1 { + schema_version: EXTERNAL_HOOK_IMPORT_SCHEMA_V1, + revision, + catalog, + imports, + diagnostics, + }) +} + +pub async fn plan_external_hook_import( + workspace: Option<&Path>, + source: SourceKey, +) -> ExternalSourceOperationResult { + Ok(build_plan(workspace, source).await?.plan) +} + +pub async fn apply_external_hook_import( + workspace: Option<&Path>, + request: ExternalHookImportApplyRequestV1, +) -> ExternalSourceOperationResult { + if request.schema_version != EXTERNAL_HOOK_IMPORT_SCHEMA_V1 { + return Err(invalid_request("Unsupported Hook import request schema")); + } + let prepared_plan = build_plan(workspace, request.source).await?; + if prepared_plan.plan.plan_fingerprint != request.plan_fingerprint { + return Ok(ExternalHookImportApplyResultV1 { + schema_version: EXTERNAL_HOOK_IMPORT_SCHEMA_V1, + outcome: ExternalHookImportApplyOutcomeV1::Stale { + refreshed_plan: prepared_plan.plan, + }, + }); + } + let hooks_json = prepared_plan + .hooks_json + .ok_or_else(|| invalid_request("This Hook source has no compatible command handlers"))?; + let write = HookImportWrite { + source: prepared_plan.prepared.source, + behavior_version: prepared_plan.prepared.behavior_version, + hooks_json, + assets: prepared_plan.prepared.assets, + }; + let applied = prepared_plan + .store + .apply(prepared_plan.target_generation, write) + .await + .map_err(map_store_error)?; + let snapshot = external_hook_import_snapshot(workspace, false).await?; + let outcome = match applied { + HookImportApply::Applied => ExternalHookImportApplyOutcomeV1::Applied { snapshot }, + HookImportApply::Unchanged => ExternalHookImportApplyOutcomeV1::Unchanged { snapshot }, + }; + Ok(ExternalHookImportApplyResultV1 { + schema_version: EXTERNAL_HOOK_IMPORT_SCHEMA_V1, + outcome, + }) +} + +pub async fn mutate_external_hook_import( + workspace: Option<&Path>, + request: ExternalHookImportMutationRequestV1, +) -> ExternalSourceOperationResult { + if request.schema_version != EXTERNAL_HOOK_IMPORT_SCHEMA_V1 { + return Err(invalid_request("Unsupported Hook import mutation schema")); + } + let stores = stores_for(workspace).await?; + let user = stores.user.snapshot().await.map_err(map_store_error)?; + let workspace_snapshot = match &stores.workspace { + Some(store) => Some(store.snapshot().await.map_err(map_store_error)?), + None => None, + }; + if request.expected_revision + != combined_revision( + &stores.workspace_identity, + &user, + workspace_snapshot.as_ref(), + ) + { + return Err(ExternalSourceOperationError::new( + ExternalSourceOperationErrorCode::StaleRevision, + "Hook import state changed; refresh before retrying", + true, + )); + } + match request.action { + ExternalHookImportMutationV1::SetEnabled { import_id, enabled } => { + let (store, generation) = + locate_import_store(&stores, &user, workspace_snapshot.as_ref(), &import_id)?; + store + .set_enabled(generation, &import_id, enabled) + .await + .map_err(map_store_error)?; + } + ExternalHookImportMutationV1::Remove { import_id } => { + let (store, generation) = + locate_import_store(&stores, &user, workspace_snapshot.as_ref(), &import_id)?; + store + .remove(generation, &import_id) + .await + .map_err(map_store_error)?; + } + ExternalHookImportMutationV1::ResetCorruptStore { scope } => { + let store = match scope { + ExternalSourceScope::UserGlobal => Arc::clone(&stores.user), + ExternalSourceScope::Project | ExternalSourceScope::WorkspaceLocal => { + stores.workspace.as_ref().cloned().ok_or_else(|| { + invalid_request("No workspace Hook import store is selected") + })? + } + _ => return Err(invalid_request("Remote Hook import stores are unsupported")), + }; + store.reset_corrupt().await.map_err(map_store_error)?; + } + } + external_hook_import_snapshot(workspace, false).await +} + +pub(crate) async fn imported_hook_generation( + workspace: Option<&Path>, +) -> ExternalSourceOperationResult { + let stores = stores_for(workspace).await?; + let user_snapshot = stores.user.snapshot().await.map_err(map_store_error)?; + let workspace_snapshot = match &stores.workspace { + Some(store) => Some(store.snapshot().await.map_err(map_store_error)?), + None => None, + }; + Ok(generation_key( + &stores.workspace_identity, + &user_snapshot, + workspace_snapshot.as_ref(), + )) +} + +pub(crate) async fn enabled_imported_hook_layers( + workspace: Option<&Path>, +) -> ExternalSourceOperationResult> +{ + let stores = stores_for(workspace).await?; + let mut layers = stores + .user + .enabled_layers() + .await + .map_err(map_store_error)?; + if let Some(store) = &stores.workspace { + layers.extend(store.enabled_layers().await.map_err(map_store_error)?); + } + Ok(layers) +} + +async fn build_plan( + workspace: Option<&Path>, + source_key: SourceKey, +) -> ExternalSourceOperationResult { + let catalog_service = service_for(workspace).await?; + let catalog = catalog_service.snapshot_or_refresh(false).await?; + let source = catalog + .sources + .iter() + .find(|source| source.key == source_key) + .cloned() + .ok_or_else(|| not_found("The selected Hook source is not in the current catalog"))?; + let prepared = catalog_service + .prepare_import(source.key.clone(), source.content_version.clone()) + .await + .map_err(map_provider_error)?; + let stores = stores_for(workspace).await?; + let store = target_store(&stores, prepared.source.scope)?; + let target = store.snapshot().await.map_err(map_store_error)?; + let bundle_root = store.planned_bundle_path(&prepared.source.key, &prepared.behavior_version); + let handlers = prepared + .handlers + .iter() + .map(|handler| handler.public_review_at(&bundle_root)) + .collect::, _>>() + .map_err(|error| invalid_request(&error.to_string()))?; + let import_id = HookImportStore::stable_import_id(&prepared.source.key); + let disposition = if handlers.is_empty() { + ExternalHookImportDispositionV1::Unavailable + } else { + match target + .imports + .iter() + .find(|record| record.import_id == import_id) + { + None => ExternalHookImportDispositionV1::Import, + Some(record) if record.behavior_version == prepared.behavior_version => { + ExternalHookImportDispositionV1::Unchanged + } + Some(_) => ExternalHookImportDispositionV1::Update, + } + }; + let fingerprint = plan_fingerprint( + &prepared.source.key, + &prepared.source.content_version, + &prepared.behavior_version, + &handlers, + &prepared.skipped, + target.generation, + ); + let hooks_json = (!handlers.is_empty()) + .then(|| native_hook_document(&handlers)) + .transpose()?; + let plan = ExternalHookImportPlanV1 { + schema_version: EXTERNAL_HOOK_IMPORT_SCHEMA_V1, + source: prepared.source.clone(), + disposition, + behavior_version: prepared.behavior_version.clone(), + handlers, + skipped: prepared.skipped.clone(), + plan_fingerprint: fingerprint, + }; + plan.validate() + .map_err(|error| invalid_request(&error.to_string()))?; + Ok(PreparedPlan { + plan, + prepared, + hooks_json, + store, + target_generation: target.generation, + }) +} + +fn native_hook_document( + handlers: &[ExternalHookImportHandlerV1], +) -> ExternalSourceOperationResult> { + let mut events = BTreeMap::>::new(); + for handler in handlers { + let mut native_handler = Map::new(); + native_handler.insert("type".to_string(), Value::String("command".to_string())); + native_handler.insert( + "command".to_string(), + Value::String(handler.command.clone()), + ); + if let Some(command) = &handler.command_windows { + native_handler.insert("commandWindows".to_string(), Value::String(command.clone())); + } + if let Some(timeout) = handler.timeout_seconds { + native_handler.insert("timeout".to_string(), Value::from(timeout)); + } + if let Some(status) = &handler.status_message { + native_handler.insert("statusMessage".to_string(), Value::String(status.clone())); + } + let mut group = Map::new(); + if let Some(matcher) = &handler.matcher { + group.insert("matcher".to_string(), Value::String(matcher.clone())); + } + group.insert( + "hooks".to_string(), + Value::Array(vec![Value::Object(native_handler)]), + ); + events + .entry(handler.event.clone()) + .or_default() + .push(Value::Object(group)); + } + serde_json::to_vec(&serde_json::json!({ "hooks": events })).map_err(|error| { + ExternalSourceOperationError::new( + ExternalSourceOperationErrorCode::Internal, + format!("Failed to build native Hook document: {error}"), + false, + ) + }) +} + +fn plan_fingerprint( + source: &SourceKey, + catalog_content_version: &str, + behavior_version: &str, + handlers: &[ExternalHookImportHandlerV1], + skipped: &[ExternalHookImportSkippedV1], + target_generation: u64, +) -> String { + let mut hasher = Sha256::new(); + hash_part(&mut hasher, source.stable_key().as_bytes()); + hash_part(&mut hasher, catalog_content_version.as_bytes()); + hash_part(&mut hasher, behavior_version.as_bytes()); + hash_part( + &mut hasher, + &serde_json::to_vec(handlers).unwrap_or_default(), + ); + hash_part( + &mut hasher, + &serde_json::to_vec(skipped).unwrap_or_default(), + ); + hash_part(&mut hasher, &target_generation.to_be_bytes()); + format!("sha256:{}", hex::encode(hasher.finalize())) +} + +fn combined_revision( + workspace_identity: &str, + user: &HookImportStoreSnapshot, + workspace: Option<&HookImportStoreSnapshot>, +) -> String { + let mut hasher = Sha256::new(); + hash_part(&mut hasher, workspace_identity.as_bytes()); + hash_snapshot(&mut hasher, user); + if let Some(workspace) = workspace { + hash_snapshot(&mut hasher, workspace); + } else { + hash_part(&mut hasher, b"no-workspace-store"); + } + format!("sha256:{}", hex::encode(hasher.finalize())) +} + +fn generation_key( + workspace_identity: &str, + user: &HookImportStoreSnapshot, + workspace: Option<&HookImportStoreSnapshot>, +) -> u64 { + let revision = combined_revision(workspace_identity, user, workspace); + u64::from_be_bytes( + Sha256::digest(revision.as_bytes())[..8] + .try_into() + .expect("SHA-256 prefix length is fixed"), + ) +} + +fn hash_snapshot(hasher: &mut Sha256, snapshot: &HookImportStoreSnapshot) { + hash_part(hasher, &snapshot.generation.to_be_bytes()); + hash_part( + hasher, + snapshot + .corrupt_marker + .as_deref() + .unwrap_or("ready") + .as_bytes(), + ); +} + +fn hash_part(hasher: &mut Sha256, value: &[u8]) { + hasher.update((value.len() as u64).to_be_bytes()); + hasher.update(value); +} + +async fn import_state( + record: &HookImportRecord, + catalog_sources: &[bitfun_product_domains::external_hook_catalog::ExternalHookSource], + catalog_service: &Arc, + refresh_updates: bool, +) -> ImportedHookSourceStateV1 { + if !record.bundle_is_valid() { + return ImportedHookSourceStateV1::BundleMissing; + } + let Some(source) = catalog_sources + .iter() + .find(|source| source.key == record.source.key) + else { + return ImportedHookSourceStateV1::SourceMissing; + }; + if !refresh_updates { + return ImportedHookSourceStateV1::Current; + } + match catalog_service + .prepare_import(source.key.clone(), source.content_version.clone()) + .await + { + Ok(prepared) if prepared.behavior_version != record.behavior_version => { + ImportedHookSourceStateV1::UpdateAvailable + } + Ok(_) => ImportedHookSourceStateV1::Current, + Err(_) => ImportedHookSourceStateV1::UpdateCheckFailed, + } +} + +fn locate_import_store( + stores: &StoreSet, + user: &HookImportStoreSnapshot, + workspace: Option<&HookImportStoreSnapshot>, + import_id: &str, +) -> ExternalSourceOperationResult<(Arc, u64)> { + if user + .imports + .iter() + .any(|record| record.import_id == import_id) + { + return Ok((Arc::clone(&stores.user), user.generation)); + } + if let (Some(store), Some(snapshot)) = (&stores.workspace, workspace) { + if snapshot + .imports + .iter() + .any(|record| record.import_id == import_id) + { + return Ok((Arc::clone(store), snapshot.generation)); + } + } + Err(not_found("The selected Hook import does not exist")) +} + +fn target_store( + stores: &StoreSet, + scope: ExternalSourceScope, +) -> ExternalSourceOperationResult> { + match scope { + ExternalSourceScope::UserGlobal => Ok(Arc::clone(&stores.user)), + ExternalSourceScope::Project | ExternalSourceScope::WorkspaceLocal => stores + .workspace + .as_ref() + .cloned() + .ok_or_else(|| invalid_request("A workspace is required for this Hook source")), + _ => Err(invalid_request( + "Remote Hook sources cannot be imported locally", + )), + } +} + +fn append_corrupt_diagnostic( + diagnostics: &mut Vec, + scope: ExternalSourceScope, + snapshot: &HookImportStoreSnapshot, +) { + if snapshot.corrupt_marker.is_some() { + let scope_key = match scope { + ExternalSourceScope::UserGlobal => "user_global", + ExternalSourceScope::Project | ExternalSourceScope::WorkspaceLocal => "project", + _ => "unsupported", + }; + diagnostics.push( + ExternalSourceDiagnostic::error( + format!("external_hook.import_store_corrupt.{scope_key}"), + format!("The {scope:?} Hook import index is invalid; imported Hooks are disabled"), + None, + ) + .with_asset_kind(ExternalSourceAssetKind::Hook), + ); + } +} + +fn map_store_error(error: HookImportStoreError) -> ExternalSourceOperationError { + let (code, retryable) = match error { + HookImportStoreError::StaleGeneration => { + (ExternalSourceOperationErrorCode::StaleRevision, true) + } + HookImportStoreError::InvalidInput(_) => { + (ExternalSourceOperationErrorCode::InvalidRequest, false) + } + HookImportStoreError::Corrupt => (ExternalSourceOperationErrorCode::Unavailable, false), + HookImportStoreError::Io(_) => (ExternalSourceOperationErrorCode::Internal, true), + }; + ExternalSourceOperationError::new(code, error.to_string(), retryable) +} + +fn map_provider_error(error: ExternalSourceProviderError) -> ExternalSourceOperationError { + let code = if error.code.ends_with("unsupported") || error.code.ends_with("import_unsupported") + { + ExternalSourceOperationErrorCode::Unsupported + } else if error.code.ends_with("stale") { + ExternalSourceOperationErrorCode::StaleRevision + } else { + ExternalSourceOperationErrorCode::Unavailable + }; + ExternalSourceOperationError::new(code, error.message, error.transient) +} + +fn invalid_request(detail: &str) -> ExternalSourceOperationError { + ExternalSourceOperationError::new( + ExternalSourceOperationErrorCode::InvalidRequest, + detail, + false, + ) +} + +fn not_found(detail: &str) -> ExternalSourceOperationError { + ExternalSourceOperationError::new(ExternalSourceOperationErrorCode::NotFound, detail, false) +} + +#[cfg(test)] +mod tests { + use super::*; + use bitfun_agent_runtime::native_hooks::{ + AgentHookEvent, AgentHookScope, AgentHookSettings, AgentHookSettingsLayer, + }; + use bitfun_product_domains::external_hook_import::{ + ExternalHookImportHandlerV1, ExternalHookImportSkippedV1, + }; + + fn handler(command: &str) -> ExternalHookImportHandlerV1 { + ExternalHookImportHandlerV1 { + stable_key: "hook-one".to_string(), + event: "PreToolUse".to_string(), + matcher: Some("Bash".to_string()), + command: command.to_string(), + command_windows: Some(format!("win-{command}")), + timeout_seconds: Some(17), + status_message: Some("Checking".to_string()), + dependencies: Vec::new(), + } + } + + #[test] + fn native_document_is_accepted_by_the_existing_runtime_parser() { + let bytes = native_hook_document(&[handler("check")]).unwrap(); + let (settings, issues) = AgentHookSettings::from_layers(&[AgentHookSettingsLayer { + scope: AgentHookScope::User, + source: "test".to_string(), + bytes, + }]); + assert!(issues.is_empty()); + let rule = &settings.rules_for(AgentHookEvent::PreToolUse)[0]; + assert_eq!(rule.handlers[0].command, "check"); + assert_eq!( + rule.handlers[0].command_windows.as_deref(), + Some("win-check") + ); + } + + #[test] + fn plan_fingerprint_fences_commands_catalog_and_target_generation() { + let source_key = SourceKey::new("codex.hooks", "user-hooks-json").unwrap(); + let skipped = vec![ExternalHookImportSkippedV1 { + reason_code: "unsupported_event".to_string(), + count: 1, + }]; + let first = plan_fingerprint( + &source_key, + "catalog", + "behavior", + &[handler("one")], + &skipped, + 1, + ); + assert_ne!( + first, + plan_fingerprint( + &source_key, + "catalog", + "behavior", + &[handler("two")], + &skipped, + 1 + ) + ); + assert_ne!( + first, + plan_fingerprint( + &source_key, + "catalog-2", + "behavior", + &[handler("one")], + &skipped, + 1 + ) + ); + assert_ne!( + first, + plan_fingerprint( + &source_key, + "catalog", + "behavior", + &[handler("one")], + &skipped, + 2 + ) + ); + } +} diff --git a/src/crates/assembly/core/src/external_hooks.rs b/src/crates/assembly/core/src/external_hooks.rs index a70dd61be2..67b8877899 100644 --- a/src/crates/assembly/core/src/external_hooks.rs +++ b/src/crates/assembly/core/src/external_hooks.rs @@ -19,8 +19,10 @@ 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_hook_import::PreparedExternalHookImport; use bitfun_product_domains::external_sources::{ ExternalSourceOperationError, ExternalSourceOperationErrorCode, ExternalSourceOperationResult, + ExternalSourceProviderError, SourceKey, }; use std::collections::BTreeMap; use std::path::PathBuf; @@ -37,6 +39,16 @@ const HOOK_PROVIDER_DISCOVERY_TIMEOUT: Duration = Duration::from_millis(100); pub(crate) struct WorkspaceExternalHookCatalogService { coordinator: Arc, refresh_gate: tokio::sync::Mutex<()>, + preparations: tokio::sync::Mutex< + BTreeMap< + (SourceKey, String), + Arc< + tokio::sync::OnceCell< + Result, + >, + >, + >, + >, } impl WorkspaceExternalHookCatalogService { @@ -47,6 +59,7 @@ impl WorkspaceExternalHookCatalogService { Ok(Self { coordinator: Arc::new(ExternalHookCatalogCoordinator::new(context, providers)?), refresh_gate: tokio::sync::Mutex::new(()), + preparations: tokio::sync::Mutex::new(BTreeMap::new()), }) } @@ -97,6 +110,47 @@ impl WorkspaceExternalHookCatalogService { ); Ok(snapshot) } + + pub(crate) async fn prepare_import( + &self, + source: SourceKey, + expected_catalog_content_version: String, + ) -> Result { + let key = (source.clone(), expected_catalog_content_version.clone()); + let cell = { + let mut preparations = self.preparations.lock().await; + Arc::clone( + preparations + .entry(key.clone()) + .or_insert_with(|| Arc::new(tokio::sync::OnceCell::new())), + ) + }; + let coordinator = Arc::clone(&self.coordinator); + let result = cell + .get_or_init(|| async move { + tokio::task::spawn_blocking(move || { + coordinator.prepare_import(&source, &expected_catalog_content_version) + }) + .await + .unwrap_or_else(|error| { + Err(ExternalSourceProviderError::new( + "external_hook.import_worker_failed", + format!("Hook import preparation worker failed: {error}"), + true, + )) + }) + }) + .await + .clone(); + let mut preparations = self.preparations.lock().await; + if preparations + .get(&key) + .is_some_and(|current| Arc::ptr_eq(current, &cell)) + { + preparations.remove(&key); + } + result + } } fn schedule_deferred_hook_discovery( @@ -150,7 +204,7 @@ fn next_access_tick() -> u64 { ACCESS_TICK.fetch_add(1, Ordering::Relaxed) } -async fn service_for( +pub(crate) async fn service_for( workspace_root: Option<&std::path::Path>, ) -> ExternalSourceOperationResult> { let workspace_root = normalize_workspace_root(workspace_root).map_err(|error| { diff --git a/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs b/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs index 9eb613c1e4..c203a8653f 100644 --- a/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs +++ b/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs @@ -556,14 +556,14 @@ impl PathManager { } #[cfg(unix)] - fn native_path_digest(path: &Path) -> String { + pub(crate) fn native_path_digest(path: &Path) -> String { use std::os::unix::ffi::OsStrExt; hex::encode(Sha256::digest(path.as_os_str().as_bytes())) } #[cfg(windows)] - fn native_path_digest(path: &Path) -> String { + pub(crate) fn native_path_digest(path: &Path) -> String { use std::os::windows::ffi::OsStrExt; let mut hasher = Sha256::new(); @@ -574,7 +574,7 @@ impl PathManager { } #[cfg(not(any(unix, windows)))] - fn native_path_digest(path: &Path) -> String { + pub(crate) fn native_path_digest(path: &Path) -> String { hex::encode(Sha256::digest(path.to_string_lossy().as_bytes())) } diff --git a/src/crates/assembly/core/src/lib.rs b/src/crates/assembly/core/src/lib.rs index 99f8e180ff..9f4e98ec79 100644 --- a/src/crates/assembly/core/src/lib.rs +++ b/src/crates/assembly/core/src/lib.rs @@ -8,6 +8,8 @@ #[cfg(feature = "product-full")] pub mod agentic; // Agent system, tool system, and product runtime orchestration #[cfg(feature = "product-full")] +pub mod external_hook_import; +#[cfg(feature = "product-full")] pub mod external_hooks; #[cfg(all(test, feature = "product-full"))] mod external_hooks_tests; diff --git a/src/crates/assembly/core/src/native_hooks.rs b/src/crates/assembly/core/src/native_hooks.rs index 04b6bc2687..435356b93a 100644 --- a/src/crates/assembly/core/src/native_hooks.rs +++ b/src/crates/assembly/core/src/native_hooks.rs @@ -449,6 +449,7 @@ struct CachedHookEngine { engine: Arc, fingerprints: Vec, project_hooks_enabled: bool, + imported_generation: u64, } type EngineCache = tokio::sync::Mutex, CachedHookEngine>>; @@ -522,6 +523,7 @@ fn read_layers(paths: &[(AgentHookScope, PathBuf)]) -> (Vec AgentHookEngine { let (layers, skipped) = read_layers(paths); for message in &skipped { @@ -547,18 +549,46 @@ async fn engine_for( .iter() .map(|(_, path)| fingerprint(path.clone())) .collect::>(); + let imported_generation = + match crate::external_hook_import::imported_hook_generation(workspace_root).await { + Ok(generation) => generation, + Err(error) => { + warn!("Imported Hook state is unavailable: {error}"); + 0 + } + }; { let cache = engine_cache().lock().await; if let Some(cached) = cache.get(&key) { - if cached.fingerprints == fingerprints - && cached.project_hooks_enabled == project_hooks_enabled - { - return Some(Arc::clone(&cached.engine)); + if let Some(engine) = reusable_cached_engine( + cached, + &fingerprints, + project_hooks_enabled, + imported_generation, + ) { + return Some(engine); } } } - let engine = Arc::new(build_engine(&paths)); + let imported_layers = + match crate::external_hook_import::enabled_imported_hook_layers(workspace_root).await { + Ok(layers) => layers, + Err(error) => { + warn!("Imported Hook layers are unavailable: {error}"); + Vec::new() + } + }; + let (manual_layers, skipped) = read_layers(&paths); + for message in &skipped { + warn!("{message}"); + } + let layers = ordered_layers(manual_layers, imported_layers); + let (settings, issues) = AgentHookSettings::from_layers(&layers); + for issue in &issues { + warn!("Agent hook configuration issue: {issue}"); + } + let engine = Arc::new(AgentHookEngine::new(settings)); let mut cache = engine_cache().lock().await; if cache.len() >= MAX_CACHED_WORKSPACE_ENGINES && !cache.contains_key(&key) { let oldest = cache.keys().next().cloned(); @@ -572,11 +602,45 @@ async fn engine_for( engine: Arc::clone(&engine), fingerprints, project_hooks_enabled, + imported_generation, }, ); Some(engine) } +fn reusable_cached_engine( + cached: &CachedHookEngine, + fingerprints: &[HookFileFingerprint], + project_hooks_enabled: bool, + imported_generation: u64, +) -> Option> { + (cached.fingerprints == fingerprints + && cached.project_hooks_enabled == project_hooks_enabled + && cached.imported_generation == imported_generation) + .then(|| Arc::clone(&cached.engine)) +} + +#[cfg(test)] +mod cache_tests { + use super::*; + + #[test] + fn imported_generation_replaces_the_next_engine_without_invalidating_a_captured_one() { + let captured = Arc::new(AgentHookEngine::new(Default::default())); + let cached = CachedHookEngine { + engine: Arc::clone(&captured), + fingerprints: Vec::new(), + project_hooks_enabled: false, + imported_generation: 7, + }; + + let reused = reusable_cached_engine(&cached, &[], false, 7).unwrap(); + assert!(Arc::ptr_eq(&captured, &reused)); + assert!(reusable_cached_engine(&cached, &[], false, 8).is_none()); + assert_eq!(Arc::strong_count(&captured), 3); + } +} + /// One `type: "command"` handler as configured, for read-only display. #[derive(Debug, Clone)] pub struct NativeHookHandlerView { @@ -633,17 +697,35 @@ pub struct NativeHookOverview { pub async fn overview(workspace_root: Option<&Path>) -> NativeHookOverview { // Ask for every candidate path, then mark which layers a dispatch would // actually load, so the view can show a gated-off project file. - build_overview( - hooks_config().await, + let config = hooks_config().await; + let imported_layers = if config.enabled { + crate::external_hook_import::enabled_imported_hook_layers(workspace_root) + .await + .unwrap_or_default() + } else { + Vec::new() + }; + build_overview_with_imports( + config, hook_settings_paths(workspace_root, true), + imported_layers, ) } +#[cfg(test)] pub(crate) fn build_overview( config: AgentHooksConfig, candidates: Vec<(AgentHookScope, PathBuf)>, ) -> NativeHookOverview { - let files = candidates + build_overview_with_imports(config, candidates, Vec::new()) +} + +pub(crate) fn build_overview_with_imports( + config: AgentHooksConfig, + candidates: Vec<(AgentHookScope, PathBuf)>, + imported_layers: Vec, +) -> NativeHookOverview { + let mut files = candidates .iter() .map(|(scope, path)| NativeHookFileView { scope: scope.as_str(), @@ -660,7 +742,14 @@ pub(crate) fn build_overview( .filter(|(_, file)| file.loaded) .map(|(candidate, _)| candidate) .collect::>(); - let (layers, skipped) = read_layers(&loaded_paths); + let (manual_layers, skipped) = read_layers(&loaded_paths); + files.extend(imported_layers.iter().map(|layer| NativeHookFileView { + scope: layer.scope.as_str(), + path: PathBuf::from(&layer.source), + exists: true, + loaded: config.enabled, + })); + let layers = ordered_layers(manual_layers, imported_layers); let (settings, issues) = AgentHookSettings::from_layers(&layers); let mut rules = Vec::new(); @@ -704,3 +793,33 @@ pub(crate) fn build_overview( .collect(), } } + +pub(crate) fn ordered_layers( + manual: Vec, + imported: Vec, +) -> Vec { + let mut layers = Vec::with_capacity(manual.len() + imported.len()); + layers.extend( + manual + .iter() + .filter(|layer| layer.scope == AgentHookScope::User) + .cloned(), + ); + layers.extend( + imported + .iter() + .filter(|layer| layer.scope == AgentHookScope::User) + .cloned(), + ); + layers.extend( + manual + .into_iter() + .filter(|layer| layer.scope == AgentHookScope::Project), + ); + layers.extend( + imported + .into_iter() + .filter(|layer| layer.scope == AgentHookScope::Project), + ); + layers +} diff --git a/src/crates/assembly/core/src/native_hooks_tests.rs b/src/crates/assembly/core/src/native_hooks_tests.rs index cac31cbcf4..af02b60256 100644 --- a/src/crates/assembly/core/src/native_hooks_tests.rs +++ b/src/crates/assembly/core/src/native_hooks_tests.rs @@ -1,8 +1,9 @@ use crate::native_hooks::{ - build_engine, build_overview, clear_session_hook_state, dispatch_pre_tool_use, - hook_settings_paths, take_pending_session_context, AgentHooksConfig, NativeHookSessionFacts, + build_engine, build_overview, build_overview_with_imports, clear_session_hook_state, + dispatch_pre_tool_use, hook_settings_paths, ordered_layers, take_pending_session_context, + AgentHooksConfig, NativeHookSessionFacts, }; -use bitfun_agent_runtime::native_hooks::{AgentHookEvent, AgentHookScope}; +use bitfun_agent_runtime::native_hooks::{AgentHookEvent, AgentHookScope, AgentHookSettingsLayer}; use serde_json::json; use std::path::{Path, PathBuf}; @@ -312,3 +313,56 @@ fn session_context_buffer_starts_empty_and_clears() { clear_session_hook_state("unknown-session"); assert!(take_pending_session_context("unknown-session").is_empty()); } + +#[test] +fn imported_layers_share_the_existing_engine_in_the_documented_scope_order() { + let layer = |scope, source: &str| AgentHookSettingsLayer { + scope, + source: source.to_string(), + bytes: br#"{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"check"}]}]}}"#.to_vec(), + }; + let ordered = ordered_layers( + vec![ + layer(AgentHookScope::User, "manual-user"), + layer(AgentHookScope::Project, "manual-project"), + ], + vec![ + layer(AgentHookScope::User, "imported-user"), + layer(AgentHookScope::Project, "imported-project"), + ], + ); + + assert_eq!( + ordered + .iter() + .map(|layer| layer.source.as_str()) + .collect::>(), + vec![ + "manual-user", + "imported-user", + "manual-project", + "imported-project" + ] + ); +} + +#[test] +fn imported_project_layers_do_not_depend_on_the_manual_project_file_gate() { + let imported = AgentHookSettingsLayer { + scope: AgentHookScope::Project, + source: "managed-project-import".to_string(), + bytes: br#"{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"check"}]}]}}"#.to_vec(), + }; + let overview = build_overview_with_imports( + AgentHooksConfig { + enabled: true, + project_hooks_enabled: false, + }, + Vec::new(), + vec![imported], + ); + + assert_eq!(overview.total_handlers, 1); + assert_eq!(overview.files.len(), 1); + assert!(overview.files[0].loaded); +} diff --git a/src/crates/assembly/external-sources/src/hook.rs b/src/crates/assembly/external-sources/src/hook.rs index e0536450ff..23fbb7b351 100644 --- a/src/crates/assembly/external-sources/src/hook.rs +++ b/src/crates/assembly/external-sources/src/hook.rs @@ -4,9 +4,10 @@ use bitfun_product_domains::external_hook_catalog::{ ExternalHookCatalogSnapshotV1, ExternalHookProviderIdentity, ExternalHookProviderSnapshot, ExternalHookSourceProvider, EXTERNAL_HOOK_CATALOG_SCHEMA_V1, }; +use bitfun_product_domains::external_hook_import::PreparedExternalHookImport; use bitfun_product_domains::external_sources::{ ExternalSourceAssetKind, ExternalSourceContext, ExternalSourceDiagnostic, - ExternalSourceDiagnosticSeverity, ExternalSourceProviderError, ProviderId, + ExternalSourceDiagnosticSeverity, ExternalSourceProviderError, ProviderId, SourceKey, }; use std::collections::BTreeSet; use std::fmt; @@ -151,6 +152,47 @@ impl ExternalHookCatalogCoordinator { lock(&self.state).snapshot.clone() } + pub fn prepare_import( + &self, + source: &SourceKey, + expected_catalog_content_version: &str, + ) -> Result { + let (provider, context) = { + let state = lock(&self.state); + let generation = state + .providers + .iter() + .find(|candidate| candidate.identity.provider_id == source.provider_id) + .ok_or_else(|| { + ExternalSourceProviderError::new( + "external_hook.import_provider_missing", + "The Hook source provider is not registered", + false, + ) + })?; + let current_source = generation + .last_success + .as_ref() + .and_then(|snapshot| snapshot.sources.iter().find(|item| item.key == *source)) + .ok_or_else(|| { + ExternalSourceProviderError::new( + "external_hook.import_source_missing", + "The Hook source is no longer present in the current catalog", + false, + ) + })?; + if current_source.content_version != expected_catalog_content_version { + return Err(ExternalSourceProviderError::new( + "external_hook.import_catalog_stale", + "The Hook source changed after the catalog was reviewed", + false, + )); + } + (Arc::clone(&generation.provider), state.context.clone()) + }; + provider.prepare_import(&context, source, expected_catalog_content_version) + } + pub async fn discover(&self, timeout: Duration) -> DiscoveryBatch { let requests = { let mut state = lock(&self.state); diff --git a/src/crates/assembly/external-sources/tests/hook_coordinator.rs b/src/crates/assembly/external-sources/tests/hook_coordinator.rs index fe4e9c9086..5e639a7188 100644 --- a/src/crates/assembly/external-sources/tests/hook_coordinator.rs +++ b/src/crates/assembly/external-sources/tests/hook_coordinator.rs @@ -6,6 +6,9 @@ use bitfun_product_domains::external_hook_catalog::{ ExternalHookSourceKind, ExternalHookSourceProvider, }; use bitfun_product_domains::external_hook_contributions::ExternalHookPoint; +use bitfun_product_domains::external_hook_import::{ + PreparedExternalHookHandler, PreparedExternalHookImport, +}; use bitfun_product_domains::external_sources::{ EcosystemId, ExecutionDomainId, ExternalSourceContext, ExternalSourceHealth, ExternalSourceProviderError, ExternalSourceScope, SourceKey, @@ -54,6 +57,40 @@ impl ExternalHookSourceProvider for FakeProvider { std::thread::sleep(*self.delay.lock().unwrap()); self.snapshot.lock().unwrap().clone() } + + fn prepare_import( + &self, + _context: &ExternalSourceContext, + source_key: &SourceKey, + _expected_catalog_content_version: &str, + ) -> Result { + let snapshot = self.snapshot.lock().unwrap().clone()?; + let source = snapshot + .sources + .into_iter() + .find(|source| source.key == *source_key) + .ok_or_else(|| { + ExternalSourceProviderError::new("fake.hook.source_missing", "missing", false) + })?; + PreparedExternalHookImport::new( + source, + vec![PreparedExternalHookHandler { + stable_key: "fake-hook".to_string(), + event: "PreToolUse".to_string(), + matcher: None, + command: "check".to_string(), + command_windows: None, + timeout_seconds: None, + status_message: None, + dependencies: Vec::new(), + }], + Vec::new(), + Vec::new(), + ) + .map_err(|error| { + ExternalSourceProviderError::new("fake.hook.invalid", error.to_string(), false) + }) + } } fn snapshot( @@ -109,6 +146,26 @@ async fn refresh( coordinator.apply_discovery_results(batch.immediate) } +#[tokio::test] +async fn preparation_is_guarded_by_the_current_redacted_source_version() { + let provider = Arc::new(FakeProvider::new("codex.hooks", "codex", "PreToolUse")); + let coordinator = + ExternalHookCatalogCoordinator::new(context(), vec![provider.clone()]).unwrap(); + let catalog = refresh(&coordinator).await; + let source = catalog.sources[0].clone(); + + assert!(coordinator + .prepare_import(&source.key, &source.content_version) + .is_ok()); + assert_eq!( + coordinator + .prepare_import(&source.key, "stale") + .unwrap_err() + .code, + "external_hook.import_catalog_stale" + ); +} + #[tokio::test] async fn catalog_is_pending_then_preserves_provider_registration_order() { let zeta = Arc::new(FakeProvider::new("zeta.hooks", "zeta", "PreToolUse")); diff --git a/src/crates/contracts/product-domains/src/external_hook_catalog.rs b/src/crates/contracts/product-domains/src/external_hook_catalog.rs index 70fbd208d4..ed43c9159e 100644 --- a/src/crates/contracts/product-domains/src/external_hook_catalog.rs +++ b/src/crates/contracts/product-domains/src/external_hook_catalog.rs @@ -5,6 +5,7 @@ //! are not part of this contract and must remain inside the source adapter. use crate::external_hook_contributions::ExternalHookPoint; +use crate::external_hook_import::PreparedExternalHookImport; use crate::external_sources::{ validate_id, EcosystemId, ExternalSourceAssetKind, ExternalSourceContext, ExternalSourceContractError, ExternalSourceDiagnostic, ExternalSourceHealth, @@ -266,6 +267,19 @@ pub trait ExternalHookSourceProvider: Send + Sync { &self, context: &ExternalSourceContext, ) -> Result; + + fn prepare_import( + &self, + _context: &ExternalSourceContext, + _source: &SourceKey, + _expected_catalog_content_version: &str, + ) -> Result { + Err(ExternalSourceProviderError::new( + "external_hook.import_unsupported", + "This Hook provider does not support command import", + false, + )) + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/src/crates/contracts/product-domains/src/external_hook_import.rs b/src/crates/contracts/product-domains/src/external_hook_import.rs new file mode 100644 index 0000000000..ced6542990 --- /dev/null +++ b/src/crates/contracts/product-domains/src/external_hook_import.rs @@ -0,0 +1,591 @@ +//! Local-only contracts for explicitly importing compatible external command Hooks. +//! +//! The public DTOs are consumed by local CLI/Desktop surfaces. Prepared types +//! cross only the adapter-to-assembly port and deliberately redact commands and +//! asset bytes from `Debug` output. + +use crate::external_hook_catalog::{ExternalHookCatalogSnapshotV1, ExternalHookSource}; +use crate::external_sources::{ + validate_id, ExternalSourceContractError, ExternalSourceDiagnostic, ExternalSourceScope, + SourceKey, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeSet; +use std::fmt; +use std::path::{Component, Path, PathBuf}; + +pub const EXTERNAL_HOOK_IMPORT_SCHEMA_V1: u32 = 1; +pub const MAX_EXTERNAL_HOOK_IMPORT_HANDLERS: usize = 2048; +pub const MAX_EXTERNAL_HOOK_IMPORT_SKIPPED_REASONS: usize = 256; +pub const MAX_EXTERNAL_HOOK_IMPORT_ASSETS: usize = 256; +pub const MAX_EXTERNAL_HOOK_IMPORT_ASSET_BYTES: usize = 1024 * 1024; +pub const MAX_EXTERNAL_HOOK_IMPORT_TOTAL_ASSET_BYTES: usize = 16 * 1024 * 1024; +pub const MAX_EXTERNAL_HOOK_IMPORT_ASSET_DEPTH: usize = 8; +pub const MANAGED_HOOK_ROOT_PLACEHOLDER: &str = "__BITFUN_MANAGED_HOOK_ROOT__"; + +const MAX_COMMAND_BYTES: usize = 64 * 1024; +const MAX_MATCHER_BYTES: usize = 512; +const MAX_TEXT_BYTES: usize = 4096; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ExternalHookImportDispositionV1 { + Import, + Update, + Unchanged, + Unavailable, +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde( + tag = "kind", + rename_all = "snake_case", + rename_all_fields = "camelCase", + deny_unknown_fields +)] +pub enum ExternalHookImportDependencyV1 { + Managed { relative_path: String }, + External { location: String }, +} + +impl fmt::Debug for ExternalHookImportDependencyV1 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Managed { .. } => formatter.write_str("Managed { path: }"), + Self::External { .. } => formatter.write_str("External { location: }"), + } + } +} + +impl ExternalHookImportDependencyV1 { + fn validate(&self) -> Result<(), ExternalSourceContractError> { + let value = match self { + Self::Managed { relative_path } => relative_path, + Self::External { location } => location, + }; + validate_bounded_text(value, MAX_TEXT_BYTES, "Hook import dependency") + } +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalHookImportHandlerV1 { + pub stable_key: String, + pub event: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub matcher: Option, + pub command: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub command_windows: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout_seconds: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status_message: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub dependencies: Vec, +} + +impl fmt::Debug for ExternalHookImportHandlerV1 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ExternalHookImportHandlerV1") + .field("stable_key", &self.stable_key) + .field("event", &self.event) + .field("matcher", &self.matcher) + .field("command", &"") + .field( + "command_windows", + &self.command_windows.as_ref().map(|_| ""), + ) + .field("timeout_seconds", &self.timeout_seconds) + .field( + "status_message", + &self.status_message.as_ref().map(|_| ""), + ) + .field("dependency_count", &self.dependencies.len()) + .finish() + } +} + +impl ExternalHookImportHandlerV1 { + fn validate(&self) -> Result<(), ExternalSourceContractError> { + validate_id(&self.stable_key, "Hook import handler")?; + validate_id(&self.event, "Hook import event")?; + if let Some(matcher) = &self.matcher { + validate_bounded_text(matcher, MAX_MATCHER_BYTES, "Hook import matcher")?; + } + validate_bounded_text(&self.command, MAX_COMMAND_BYTES, "Hook import command")?; + if let Some(command) = &self.command_windows { + validate_bounded_text(command, MAX_COMMAND_BYTES, "Hook import Windows command")?; + } + if let Some(status) = &self.status_message { + validate_bounded_text(status, MAX_TEXT_BYTES, "Hook import status")?; + } + if self.dependencies.len() > MAX_EXTERNAL_HOOK_IMPORT_ASSETS { + return Err(ExternalSourceContractError::InvalidIdentifier( + "Hook import dependency count", + )); + } + for dependency in &self.dependencies { + dependency.validate()?; + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalHookImportSkippedV1 { + pub reason_code: String, + pub count: u32, +} + +impl ExternalHookImportSkippedV1 { + fn validate(&self) -> Result<(), ExternalSourceContractError> { + validate_id(&self.reason_code, "Hook import skip reason")?; + if self.count == 0 { + return Err(ExternalSourceContractError::InvalidIdentifier( + "Hook import skip count", + )); + } + Ok(()) + } +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalHookImportPlanV1 { + pub schema_version: u32, + pub source: ExternalHookSource, + pub disposition: ExternalHookImportDispositionV1, + pub behavior_version: String, + pub handlers: Vec, + pub skipped: Vec, + pub plan_fingerprint: String, +} + +impl fmt::Debug for ExternalHookImportPlanV1 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ExternalHookImportPlanV1") + .field("schema_version", &self.schema_version) + .field("source", &self.source.key) + .field("disposition", &self.disposition) + .field("behavior_version", &self.behavior_version) + .field("handler_count", &self.handlers.len()) + .field("skipped", &self.skipped) + .field("plan_fingerprint", &self.plan_fingerprint) + .finish() + } +} + +impl ExternalHookImportPlanV1 { + pub fn validate(&self) -> Result<(), ExternalSourceContractError> { + if self.schema_version != EXTERNAL_HOOK_IMPORT_SCHEMA_V1 { + return Err(ExternalSourceContractError::InvalidIdentifier( + "Hook import schema", + )); + } + self.source.validate()?; + validate_id(&self.behavior_version, "Hook import behavior version")?; + validate_id(&self.plan_fingerprint, "Hook import plan fingerprint")?; + if self.handlers.len() > MAX_EXTERNAL_HOOK_IMPORT_HANDLERS + || self.skipped.len() > MAX_EXTERNAL_HOOK_IMPORT_SKIPPED_REASONS + { + return Err(ExternalSourceContractError::InvalidIdentifier( + "Hook import plan size", + )); + } + let mut keys = BTreeSet::new(); + for handler in &self.handlers { + handler.validate()?; + if !keys.insert(&handler.stable_key) { + return Err(ExternalSourceContractError::InvalidIdentifier( + "Hook import handler identity", + )); + } + } + for skipped in &self.skipped { + skipped.validate()?; + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalHookImportApplyRequestV1 { + pub schema_version: u32, + pub source: SourceKey, + pub plan_fingerprint: String, +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde( + tag = "kind", + rename_all = "snake_case", + rename_all_fields = "camelCase", + deny_unknown_fields +)] +pub enum ExternalHookImportApplyOutcomeV1 { + Applied { + snapshot: ExternalHookImportSnapshotV1, + }, + Unchanged { + snapshot: ExternalHookImportSnapshotV1, + }, + Stale { + refreshed_plan: ExternalHookImportPlanV1, + }, +} + +impl fmt::Debug for ExternalHookImportApplyOutcomeV1 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Applied { snapshot } => formatter + .debug_struct("Applied") + .field("revision", &snapshot.revision) + .finish(), + Self::Unchanged { snapshot } => formatter + .debug_struct("Unchanged") + .field("revision", &snapshot.revision) + .finish(), + Self::Stale { refreshed_plan } => formatter + .debug_struct("Stale") + .field("plan", refreshed_plan) + .finish(), + } + } +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalHookImportApplyResultV1 { + pub schema_version: u32, + pub outcome: ExternalHookImportApplyOutcomeV1, +} + +impl fmt::Debug for ExternalHookImportApplyResultV1 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ExternalHookImportApplyResultV1") + .field("schema_version", &self.schema_version) + .field("outcome", &self.outcome) + .finish() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ImportedHookSourceStateV1 { + Current, + UpdateAvailable, + SourceMissing, + UpdateCheckFailed, + BundleMissing, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ImportedHookSourceSnapshotV1 { + pub import_id: String, + pub source: ExternalHookSource, + pub enabled: bool, + pub behavior_version: String, + pub state: ImportedHookSourceStateV1, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalHookImportSnapshotV1 { + pub schema_version: u32, + pub revision: String, + pub catalog: ExternalHookCatalogSnapshotV1, + pub imports: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde( + tag = "kind", + rename_all = "snake_case", + rename_all_fields = "camelCase", + deny_unknown_fields +)] +pub enum ExternalHookImportMutationV1 { + SetEnabled { import_id: String, enabled: bool }, + Remove { import_id: String }, + ResetCorruptStore { scope: ExternalSourceScope }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalHookImportMutationRequestV1 { + pub schema_version: u32, + pub expected_revision: String, + pub action: ExternalHookImportMutationV1, +} + +#[derive(Clone, PartialEq, Eq)] +pub struct PreparedExternalHookHandler { + pub stable_key: String, + pub event: String, + pub matcher: Option, + pub command: String, + pub command_windows: Option, + pub timeout_seconds: Option, + pub status_message: Option, + pub dependencies: Vec, +} + +impl fmt::Debug for PreparedExternalHookHandler { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PreparedExternalHookHandler") + .field("stable_key", &self.stable_key) + .field("event", &self.event) + .field("command", &"") + .field("dependency_count", &self.dependencies.len()) + .finish() + } +} + +impl PreparedExternalHookHandler { + pub fn public_review(&self) -> ExternalHookImportHandlerV1 { + ExternalHookImportHandlerV1 { + stable_key: self.stable_key.clone(), + event: self.event.clone(), + matcher: self.matcher.clone(), + command: self.command.clone(), + command_windows: self.command_windows.clone(), + timeout_seconds: self.timeout_seconds, + status_message: self.status_message.clone(), + dependencies: self.dependencies.clone(), + } + } + + pub fn public_review_at( + &self, + managed_root: &Path, + ) -> Result { + let managed_root = managed_root.to_string_lossy().replace('\\', "/"); + if managed_root.is_empty() + || managed_root + .chars() + .any(|value| value.is_control() || matches!(value, '"' | '$' | '`' | '%' | '!')) + { + return Err(ExternalSourceContractError::InvalidText( + "managed Hook root", + )); + } + let mut review = self.public_review(); + review.command = review + .command + .replace(MANAGED_HOOK_ROOT_PLACEHOLDER, &managed_root); + review.command_windows = review + .command_windows + .map(|command| command.replace(MANAGED_HOOK_ROOT_PLACEHOLDER, &managed_root)); + review.validate()?; + Ok(review) + } + + fn validate(&self) -> Result<(), ExternalSourceContractError> { + self.public_review().validate() + } +} + +#[derive(Clone, PartialEq, Eq)] +pub struct PreparedExternalHookAsset { + pub relative_path: PathBuf, + pub bytes: Vec, +} + +impl fmt::Debug for PreparedExternalHookAsset { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PreparedExternalHookAsset") + .field("relative_path", &self.relative_path) + .field("byte_count", &self.bytes.len()) + .finish() + } +} + +#[derive(Clone, PartialEq, Eq)] +pub struct PreparedExternalHookImport { + pub source: ExternalHookSource, + pub behavior_version: String, + pub handlers: Vec, + pub skipped: Vec, + pub assets: Vec, +} + +impl fmt::Debug for PreparedExternalHookImport { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PreparedExternalHookImport") + .field("source", &self.source.key) + .field("behavior_version", &self.behavior_version) + .field("handler_count", &self.handlers.len()) + .field("skipped", &self.skipped) + .field("asset_count", &self.assets.len()) + .finish() + } +} + +impl PreparedExternalHookImport { + pub fn new( + source: ExternalHookSource, + handlers: Vec, + skipped: Vec, + mut assets: Vec, + ) -> Result { + source.validate()?; + if handlers.len() > MAX_EXTERNAL_HOOK_IMPORT_HANDLERS + || (handlers.is_empty() && skipped.is_empty()) + { + return Err(ExternalSourceContractError::InvalidIdentifier( + "prepared Hook import handler count", + )); + } + if skipped.len() > MAX_EXTERNAL_HOOK_IMPORT_SKIPPED_REASONS + || assets.len() > MAX_EXTERNAL_HOOK_IMPORT_ASSETS + { + return Err(ExternalSourceContractError::InvalidIdentifier( + "prepared Hook import size", + )); + } + let mut handler_keys = BTreeSet::new(); + for handler in &handlers { + handler.validate()?; + if !handler_keys.insert(&handler.stable_key) { + return Err(ExternalSourceContractError::InvalidIdentifier( + "prepared Hook import handler identity", + )); + } + } + for item in &skipped { + item.validate()?; + } + assets.sort_by(|left, right| left.relative_path.cmp(&right.relative_path)); + let mut asset_paths = BTreeSet::new(); + let mut total_bytes = 0usize; + for asset in &assets { + validate_asset_path(&asset.relative_path)?; + if asset.bytes.len() > MAX_EXTERNAL_HOOK_IMPORT_ASSET_BYTES + || !asset_paths.insert(asset.relative_path.clone()) + { + return Err(ExternalSourceContractError::InvalidIdentifier( + "prepared Hook import asset", + )); + } + total_bytes = total_bytes.checked_add(asset.bytes.len()).ok_or( + ExternalSourceContractError::InvalidIdentifier("prepared Hook import asset bytes"), + )?; + if total_bytes > MAX_EXTERNAL_HOOK_IMPORT_TOTAL_ASSET_BYTES { + return Err(ExternalSourceContractError::InvalidIdentifier( + "prepared Hook import asset bytes", + )); + } + } + let behavior_version = behavior_version(&source, &handlers, &assets); + Ok(Self { + source, + behavior_version, + handlers, + skipped, + assets, + }) + } +} + +fn behavior_version( + source: &ExternalHookSource, + handlers: &[PreparedExternalHookHandler], + assets: &[PreparedExternalHookAsset], +) -> String { + let mut hasher = Sha256::new(); + hash_part(&mut hasher, source.key.stable_key().as_bytes()); + for handler in handlers { + hash_part(&mut hasher, handler.stable_key.as_bytes()); + hash_part(&mut hasher, handler.event.as_bytes()); + hash_optional(&mut hasher, handler.matcher.as_deref()); + hash_part(&mut hasher, handler.command.as_bytes()); + hash_optional(&mut hasher, handler.command_windows.as_deref()); + hash_part( + &mut hasher, + handler + .timeout_seconds + .unwrap_or_default() + .to_string() + .as_bytes(), + ); + hash_optional(&mut hasher, handler.status_message.as_deref()); + for dependency in &handler.dependencies { + match dependency { + ExternalHookImportDependencyV1::Managed { relative_path } => { + hash_part(&mut hasher, b"managed"); + hash_part(&mut hasher, relative_path.as_bytes()); + } + ExternalHookImportDependencyV1::External { location } => { + hash_part(&mut hasher, b"external"); + hash_part(&mut hasher, location.as_bytes()); + } + } + } + } + for asset in assets { + hash_part( + &mut hasher, + asset.relative_path.to_string_lossy().as_bytes(), + ); + hash_part(&mut hasher, &asset.bytes); + } + format!("sha256:{}", hex::encode(hasher.finalize())) +} + +fn hash_optional(hasher: &mut Sha256, value: Option<&str>) { + match value { + Some(value) => { + hash_part(hasher, b"some"); + hash_part(hasher, value.as_bytes()); + } + None => hash_part(hasher, b"none"), + } +} + +fn hash_part(hasher: &mut Sha256, value: &[u8]) { + hasher.update((value.len() as u64).to_be_bytes()); + hasher.update(value); +} + +fn validate_asset_path(path: &PathBuf) -> Result<(), ExternalSourceContractError> { + if path.as_os_str().is_empty() + || path.is_absolute() + || path.components().count() > MAX_EXTERNAL_HOOK_IMPORT_ASSET_DEPTH + || path + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + return Err(ExternalSourceContractError::InvalidIdentifier( + "prepared Hook import asset path", + )); + } + Ok(()) +} + +fn validate_bounded_text( + value: &str, + max_bytes: usize, + label: &'static str, +) -> Result<(), ExternalSourceContractError> { + if value.is_empty() + || value.len() > max_bytes + || value.trim() != value + || value.chars().any(char::is_control) + { + return Err(ExternalSourceContractError::InvalidText(label)); + } + Ok(()) +} diff --git a/src/crates/contracts/product-domains/src/lib.rs b/src/crates/contracts/product-domains/src/lib.rs index b63296d564..d8ab07bbea 100644 --- a/src/crates/contracts/product-domains/src/lib.rs +++ b/src/crates/contracts/product-domains/src/lib.rs @@ -15,6 +15,9 @@ pub mod external_hook_contributions; #[cfg(feature = "external-sources")] pub mod external_hook_catalog; +#[cfg(feature = "external-sources")] +pub mod external_hook_import; + #[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 index 1cb76ac04c..5d06dfbbc2 100644 --- a/src/crates/contracts/product-domains/tests/external_hook_catalog_contracts.rs +++ b/src/crates/contracts/product-domains/tests/external_hook_catalog_contracts.rs @@ -5,6 +5,12 @@ use bitfun_product_domains::external_hook_catalog::{ ExternalHookSource, ExternalHookSourceKind, }; use bitfun_product_domains::external_hook_contributions::ExternalHookPoint; +use bitfun_product_domains::external_hook_import::{ + ExternalHookImportDependencyV1, ExternalHookImportDispositionV1, ExternalHookImportHandlerV1, + ExternalHookImportPlanV1, ExternalHookImportSkippedV1, PreparedExternalHookAsset, + PreparedExternalHookHandler, PreparedExternalHookImport, EXTERNAL_HOOK_IMPORT_SCHEMA_V1, + MANAGED_HOOK_ROOT_PLACEHOLDER, +}; use bitfun_product_domains::external_sources::{ EcosystemId, ExternalSourceAssetKind, ExternalSourceDiagnostic, ExternalSourceHealth, ExternalSourceScope, ProviderId, SourceKey, @@ -154,3 +160,165 @@ fn empty_catalog_is_pending_until_the_first_discovery_finishes() { assert_eq!(snapshot.schema_version, 1); assert!(snapshot.providers.is_empty()); } + +#[test] +fn import_plan_has_a_versioned_exact_wire_shape_and_redacted_debug() { + let plan = ExternalHookImportPlanV1 { + schema_version: EXTERNAL_HOOK_IMPORT_SCHEMA_V1, + source: source(), + disposition: ExternalHookImportDispositionV1::Import, + behavior_version: "sha256:behavior".to_string(), + handlers: vec![ExternalHookImportHandlerV1 { + stable_key: "pre-tool-0".to_string(), + event: "PreToolUse".to_string(), + matcher: Some("Bash".to_string()), + command: "private-command --token secret".to_string(), + command_windows: None, + timeout_seconds: Some(30), + status_message: Some("secret-status".to_string()), + dependencies: vec![ExternalHookImportDependencyV1::External { + location: "/opt/private/tool".to_string(), + }], + }], + skipped: vec![ExternalHookImportSkippedV1 { + reason_code: "unsupported_async".to_string(), + count: 1, + }], + plan_fingerprint: "sha256:plan".to_string(), + }; + + plan.validate().unwrap(); + let value = serde_json::to_value(&plan).unwrap(); + assert_eq!(value["schemaVersion"], 1); + assert_eq!(value["disposition"], "import"); + assert_eq!(value["handlers"][0]["dependencies"][0]["kind"], "external"); + assert!( + serde_json::from_value::(serde_json::json!({ + "schemaVersion": 1, + "source": value["source"], + "disposition": "import", + "behaviorVersion": "sha256:behavior", + "handlers": [], + "skipped": [], + "planFingerprint": "sha256:plan", + "unexpected": true + })) + .is_err() + ); + let debug = format!("{plan:?}"); + assert!(!debug.contains("private-command")); + assert!(!debug.contains("secret")); + assert!(!debug.contains("secret-status")); + assert!(!debug.contains("/opt/private/tool")); + + let handler_debug = format!("{:?}", plan.handlers[0]); + assert!(!handler_debug.contains("secret-status")); +} + +#[test] +fn prepared_behavior_version_tracks_commands_and_asset_bytes_without_debug_leaks() { + let prepared = |command: &str, bytes: &[u8]| { + PreparedExternalHookImport::new( + source(), + vec![PreparedExternalHookHandler { + stable_key: "pre-tool-0".to_string(), + event: "PreToolUse".to_string(), + matcher: Some("Bash".to_string()), + command: command.to_string(), + command_windows: None, + timeout_seconds: Some(30), + status_message: None, + dependencies: Vec::new(), + }], + vec![ExternalHookImportSkippedV1 { + reason_code: "unsupported_async".to_string(), + count: 1, + }], + vec![PreparedExternalHookAsset { + relative_path: "hooks/check.py".into(), + bytes: bytes.to_vec(), + }], + ) + .unwrap() + }; + + let first = prepared("python hooks/check.py", b"print('one')"); + let command_changed = prepared("python3 hooks/check.py", b"print('one')"); + let asset_changed = prepared("python hooks/check.py", b"print('two')"); + assert_ne!(first.behavior_version, command_changed.behavior_version); + assert_ne!(first.behavior_version, asset_changed.behavior_version); + let debug = format!("{first:?}"); + assert!(!debug.contains("python hooks/check.py")); + assert!(!debug.contains("print('one')")); +} + +#[test] +fn prepared_assets_reject_paths_deeper_than_the_fixed_import_budget() { + let error = PreparedExternalHookImport::new( + source(), + vec![PreparedExternalHookHandler { + stable_key: "pre-tool-0".to_string(), + event: "PreToolUse".to_string(), + matcher: None, + command: "check".to_string(), + command_windows: None, + timeout_seconds: None, + status_message: None, + dependencies: Vec::new(), + }], + Vec::new(), + vec![PreparedExternalHookAsset { + relative_path: "one/two/three/four/five/six/seven/eight/nine/check.py".into(), + bytes: vec![1], + }], + ) + .unwrap_err(); + + assert!(error.to_string().contains("asset path")); +} + +#[test] +fn prepared_import_preserves_skipped_only_preview_but_rejects_an_empty_result() { + let skipped_only = PreparedExternalHookImport::new( + source(), + Vec::new(), + vec![ExternalHookImportSkippedV1 { + reason_code: "unsupported_handler_type".to_string(), + count: 2, + }], + Vec::new(), + ) + .unwrap(); + assert!(skipped_only.handlers.is_empty()); + + assert!(PreparedExternalHookImport::new(source(), Vec::new(), Vec::new(), Vec::new()).is_err()); +} + +#[test] +fn prepared_handler_materializes_only_the_reserved_managed_root_placeholder() { + let handler = PreparedExternalHookHandler { + stable_key: "pre-tool-0".to_string(), + event: "PreToolUse".to_string(), + matcher: None, + command: format!( + "python \"{MANAGED_HOOK_ROOT_PLACEHOLDER}/hooks/check.py\" --label literal" + ), + command_windows: None, + timeout_seconds: None, + status_message: None, + dependencies: vec![ExternalHookImportDependencyV1::Managed { + relative_path: "hooks/check.py".to_string(), + }], + }; + + let review = handler + .public_review_at(std::path::Path::new("D:/managed/import")) + .unwrap(); + assert_eq!( + review.command, + "python \"D:/managed/import/hooks/check.py\" --label literal" + ); + assert!(handler + .public_review_at(std::path::Path::new("D:/unsafe/$root")) + .is_err()); +} diff --git a/src/crates/services/services-integrations/Cargo.toml b/src/crates/services/services-integrations/Cargo.toml index 5be2181575..e117305c9a 100644 --- a/src/crates/services/services-integrations/Cargo.toml +++ b/src/crates/services/services-integrations/Cargo.toml @@ -142,6 +142,16 @@ plugin-source = [ "uuid", "windows", ] +hook-import = [ + "bitfun-agent-runtime", + "bitfun-product-domains/external-sources", + "bitfun-services-core", + "dep:bitfun-product-domains", + "hex", + "sha2", + "thiserror", + "uuid", +] remote-connect = [ "anyhow", "aes", @@ -251,6 +261,7 @@ product-full = [ "file-watch", "function-agents", "git", + "hook-import", "miniapp-runtime", "mcp", "plugin-source", diff --git a/src/crates/services/services-integrations/src/hook_import.rs b/src/crates/services/services-integrations/src/hook_import.rs new file mode 100644 index 0000000000..ce97539773 --- /dev/null +++ b/src/crates/services/services-integrations/src/hook_import.rs @@ -0,0 +1,1563 @@ +//! Product-owned persistence for imported command Hook snapshots. + +use bitfun_agent_runtime::native_hooks::{ + AgentHookScope, AgentHookSettings, AgentHookSettingsLayer, MAX_HOOKS_FILE_BYTES, +}; +use bitfun_product_domains::external_hook_catalog::{ExternalHookSource, ExternalHookSourceKind}; +use bitfun_product_domains::external_hook_import::{ + PreparedExternalHookAsset, MAX_EXTERNAL_HOOK_IMPORT_ASSETS, + MAX_EXTERNAL_HOOK_IMPORT_ASSET_BYTES, MAX_EXTERNAL_HOOK_IMPORT_ASSET_DEPTH, + MAX_EXTERNAL_HOOK_IMPORT_TOTAL_ASSET_BYTES, +}; +use bitfun_product_domains::external_sources::{ + EcosystemId, ExternalSourceHealth, ExternalSourceScope, SourceKey, +}; +use bitfun_services_core::json_store::JsonFileStore; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeSet; +use std::path::{Component, Path, PathBuf}; +use std::time::UNIX_EPOCH; +use thiserror::Error; +use tokio::sync::RwLock; + +const INDEX_SCHEMA_V1: u32 = 1; +const MAX_INDEX_BYTES: u64 = 1024 * 1024; + +#[derive(Clone)] +pub struct HookImportWrite { + pub source: ExternalHookSource, + pub behavior_version: String, + pub hooks_json: Vec, + pub assets: Vec, +} + +impl std::fmt::Debug for HookImportWrite { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("HookImportWrite") + .field("source", &self.source.key) + .field("behavior_version", &self.behavior_version) + .field("hooks_json", &"") + .field("asset_count", &self.assets.len()) + .finish() + } +} + +#[derive(Debug, Clone)] +pub struct HookImportRecord { + pub import_id: String, + pub source: ExternalHookSource, + pub enabled: bool, + pub behavior_version: String, + pub bundle_path: PathBuf, + content_digest: String, + bundle_valid: bool, +} + +impl HookImportRecord { + /// Whether the exact indexed managed snapshot passed bounded content verification. + pub fn bundle_is_valid(&self) -> bool { + self.bundle_valid + } +} + +#[derive(Debug, Clone, Default)] +pub struct HookImportStoreSnapshot { + pub generation: u64, + pub imports: Vec, + pub corrupt_marker: Option, +} + +#[derive(Debug, Clone)] +pub enum HookImportApply { + Applied, + Unchanged, +} + +#[derive(Debug, Error)] +pub enum HookImportStoreError { + #[error("Hook import store generation changed")] + StaleGeneration, + #[error("Hook import store is corrupt")] + Corrupt, + #[error("invalid Hook import: {0}")] + InvalidInput(&'static str), + #[error("Hook import IO failed: {0}")] + Io(String), +} + +pub struct HookImportStore { + root: PathBuf, + scope: ExternalSourceScope, + state: RwLock, +} + +struct CachedStore { + snapshot: HookImportStoreSnapshot, + fingerprint: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct IndexFingerprint { + len: u64, + modified_nanos: u128, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct StoreIndexV1 { + schema_version: u32, + generation: u64, + imports: Vec, +} + +impl Default for StoreIndexV1 { + fn default() -> Self { + Self { + schema_version: INDEX_SCHEMA_V1, + generation: 0, + imports: Vec::new(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct StoreRecordV1 { + import_id: String, + source: SourceKey, + ecosystem_id: EcosystemId, + display_name: String, + source_kind: ExternalHookSourceKind, + scope: ExternalSourceScope, + location_hint: String, + catalog_content_version: String, + behavior_version: String, + bundle_digest: String, + content_digest: String, + enabled: bool, +} + +enum LoadedIndex { + Missing, + Ready(StoreIndexV1), + Corrupt(String), +} + +impl HookImportStore { + pub async fn open( + root: PathBuf, + scope: ExternalSourceScope, + ) -> Result { + if !root.is_absolute() { + return Err(HookImportStoreError::InvalidInput( + "store root must be absolute", + )); + } + validate_store_root(&root).await?; + let loaded = load_index(&root.join("index.json")).await?; + let mut snapshot = snapshot_from_loaded(&root, scope, &loaded)?; + verify_snapshot_bundles(&root, &mut snapshot).await; + let fingerprint = index_fingerprint(&root.join("index.json")).await?; + Ok(Self { + root, + scope, + state: RwLock::new(CachedStore { + snapshot, + fingerprint, + }), + }) + } + + pub async fn snapshot(&self) -> Result { + self.refresh_if_changed().await?; + Ok(self.state.read().await.snapshot.clone()) + } + + pub fn stable_import_id(source: &SourceKey) -> String { + import_id(source) + } + + pub fn planned_bundle_path(&self, source: &SourceKey, behavior_version: &str) -> PathBuf { + self.root + .join("bundles") + .join(import_id(source)) + .join(behavior_directory(behavior_version)) + } + + pub async fn apply( + &self, + expected_generation: u64, + write: HookImportWrite, + ) -> Result { + validate_write(self.scope, &write)?; + ensure_store_root(&self.root).await?; + let index_path = self.root.join("index.json"); + let json_store = JsonFileStore; + let _lock = json_store + .acquire_cross_process_lock(&index_path) + .await + .map_err(io_error)?; + let mut index = match load_index(&index_path).await? { + LoadedIndex::Missing => StoreIndexV1::default(), + LoadedIndex::Ready(index) => { + validate_index(self.scope, &index)?; + index + } + LoadedIndex::Corrupt(_) => return Err(HookImportStoreError::Corrupt), + }; + if index.generation != expected_generation { + return Err(HookImportStoreError::StaleGeneration); + } + + let import_id = import_id(&write.source.key); + let bundle_digest = bundle_digest(&write); + let content_digest = bundle_content_digest(&write); + let bundle_path = self + .root + .join("bundles") + .join(&import_id) + .join(&bundle_digest); + let index_is_unchanged = index.imports.iter().any(|record| { + record.import_id == import_id + && record.behavior_version == write.behavior_version + && record.bundle_digest == bundle_digest + && record.content_digest == content_digest + }); + if index_is_unchanged + && validate_bundle_content(&self.root, &bundle_path, &content_digest) + .await + .is_ok() + { + let snapshot = verified_snapshot_from_index(&self.root, self.scope, &index).await?; + self.replace_cached(snapshot).await?; + return Ok(HookImportApply::Unchanged); + } + let next_generation = index + .generation + .checked_add(1) + .ok_or(HookImportStoreError::InvalidInput("generation overflow"))?; + let publication = publish_bundle(&self.root, &bundle_path, &write, &content_digest).await?; + let previous_bundle = index + .imports + .iter() + .find(|record| record.import_id == import_id) + .map(|record| record.bundle_digest.clone()); + let enabled = index + .imports + .iter() + .find(|record| record.import_id == import_id) + .is_none_or(|record| record.enabled); + index.imports.retain(|record| record.import_id != import_id); + index.imports.push(StoreRecordV1::from_write( + import_id.clone(), + bundle_digest.clone(), + content_digest, + enabled, + &write, + )); + index + .imports + .sort_by(|left, right| left.import_id.cmp(&right.import_id)); + index.generation = next_generation; + if let Err(error) = json_store.write_atomic_strict(&index_path, &index).await { + let publication_error = io_error(error); + if let Err(rollback_error) = publication.rollback().await { + return Err(HookImportStoreError::Io(format!( + "index publication failed ({publication_error}); restoring the indexed bundle failed ({rollback_error})" + ))); + } + return Err(publication_error); + } + let snapshot = verified_snapshot_from_index(&self.root, self.scope, &index).await?; + self.replace_cached(snapshot).await?; + publication.finalize().await; + + if let Some(previous_bundle) = previous_bundle.filter(|value| value != &bundle_digest) { + let old_path = self + .root + .join("bundles") + .join(&import_id) + .join(previous_bundle); + let _ = remove_owned_path(&self.root, &old_path).await; + } + Ok(HookImportApply::Applied) + } + + pub async fn set_enabled( + &self, + expected_generation: u64, + import_id: &str, + enabled: bool, + ) -> Result { + if !safe_component(import_id) { + return Err(HookImportStoreError::InvalidInput("import id")); + } + if enabled { + let snapshot = self.snapshot().await?; + let record = snapshot + .imports + .iter() + .find(|record| record.import_id == import_id) + .ok_or(HookImportStoreError::InvalidInput("unknown import id"))?; + if !record.bundle_valid + || validate_bundle_content(&self.root, &record.bundle_path, &record.content_digest) + .await + .is_err() + { + return Err(HookImportStoreError::InvalidInput( + "bundle missing or invalid", + )); + } + } + self.update_index(expected_generation, |index| { + let record = index + .imports + .iter_mut() + .find(|record| record.import_id == import_id) + .ok_or(HookImportStoreError::InvalidInput("unknown import id"))?; + record.enabled = enabled; + Ok(()) + }) + .await + } + + pub async fn remove( + &self, + expected_generation: u64, + import_id: &str, + ) -> Result { + if !safe_component(import_id) { + return Err(HookImportStoreError::InvalidInput("import id")); + } + ensure_store_root(&self.root).await?; + let index_path = self.root.join("index.json"); + let json_store = JsonFileStore; + let _lock = json_store + .acquire_cross_process_lock(&index_path) + .await + .map_err(io_error)?; + let mut index = match load_index(&index_path).await? { + LoadedIndex::Missing => StoreIndexV1::default(), + LoadedIndex::Ready(index) => { + validate_index(self.scope, &index)?; + index + } + LoadedIndex::Corrupt(_) => return Err(HookImportStoreError::Corrupt), + }; + if index.generation != expected_generation { + return Err(HookImportStoreError::StaleGeneration); + } + let removed_digest = index + .imports + .iter() + .find(|record| record.import_id == import_id) + .map(|record| record.bundle_digest.clone()) + .ok_or(HookImportStoreError::InvalidInput("unknown import id"))?; + index.imports.retain(|record| record.import_id != import_id); + index.generation = index + .generation + .checked_add(1) + .ok_or(HookImportStoreError::InvalidInput("generation overflow"))?; + json_store + .write_atomic_strict(&index_path, &index) + .await + .map_err(io_error)?; + let snapshot = verified_snapshot_from_index(&self.root, self.scope, &index).await?; + self.replace_cached(snapshot.clone()).await?; + let removed_path = self + .root + .join("bundles") + .join(import_id) + .join(removed_digest); + let _ = remove_owned_path(&self.root, &removed_path).await; + Ok(snapshot) + } + + pub async fn reset_corrupt(&self) -> Result { + ensure_store_root(&self.root).await?; + let index_path = self.root.join("index.json"); + let json_store = JsonFileStore; + let _lock = json_store + .acquire_cross_process_lock(&index_path) + .await + .map_err(io_error)?; + if !matches!(load_index(&index_path).await?, LoadedIndex::Corrupt(_)) { + return Err(HookImportStoreError::InvalidInput("store is not corrupt")); + } + let mut index = StoreIndexV1::default(); + index.generation = reset_generation(); + json_store + .write_atomic_strict(&index_path, &index) + .await + .map_err(io_error)?; + let snapshot = verified_snapshot_from_index(&self.root, self.scope, &index).await?; + self.replace_cached(snapshot.clone()).await?; + Ok(snapshot) + } + + pub async fn enabled_layers( + &self, + ) -> Result, HookImportStoreError> { + let snapshot = self.snapshot().await?; + let mut layers = Vec::new(); + for record in snapshot + .imports + .into_iter() + .filter(|record| record.enabled && record.bundle_valid) + { + let hooks_path = record.bundle_path.join("hooks.json"); + let bytes = + match read_verified_bundle(&self.root, &record.bundle_path, &record.content_digest) + .await + { + Ok(bytes) => bytes, + Err(_) => continue, + }; + let layer = AgentHookSettingsLayer { + scope: hook_scope(self.scope), + source: hooks_path.to_string_lossy().to_string(), + bytes, + }; + let (settings, issues) = AgentHookSettings::from_layers(std::slice::from_ref(&layer)); + if issues.is_empty() && !settings.is_empty() { + layers.push(layer); + } + } + Ok(layers) + } + + async fn update_index( + &self, + expected_generation: u64, + mutate: impl FnOnce(&mut StoreIndexV1) -> Result<(), HookImportStoreError>, + ) -> Result { + ensure_store_root(&self.root).await?; + let index_path = self.root.join("index.json"); + let json_store = JsonFileStore; + let _lock = json_store + .acquire_cross_process_lock(&index_path) + .await + .map_err(io_error)?; + let mut index = match load_index(&index_path).await? { + LoadedIndex::Missing => StoreIndexV1::default(), + LoadedIndex::Ready(index) => { + validate_index(self.scope, &index)?; + index + } + LoadedIndex::Corrupt(_) => return Err(HookImportStoreError::Corrupt), + }; + if index.generation != expected_generation { + return Err(HookImportStoreError::StaleGeneration); + } + mutate(&mut index)?; + index.generation = index + .generation + .checked_add(1) + .ok_or(HookImportStoreError::InvalidInput("generation overflow"))?; + json_store + .write_atomic_strict(&index_path, &index) + .await + .map_err(io_error)?; + let snapshot = verified_snapshot_from_index(&self.root, self.scope, &index).await?; + self.replace_cached(snapshot.clone()).await?; + Ok(snapshot) + } + + async fn refresh_if_changed(&self) -> Result<(), HookImportStoreError> { + let index_path = self.root.join("index.json"); + let fingerprint = index_fingerprint(&index_path).await?; + if self.state.read().await.fingerprint == fingerprint { + return Ok(()); + } + validate_store_root(&self.root).await?; + let loaded = load_index(&index_path).await?; + let mut snapshot = snapshot_from_loaded(&self.root, self.scope, &loaded)?; + verify_snapshot_bundles(&self.root, &mut snapshot).await; + *self.state.write().await = CachedStore { + snapshot, + fingerprint, + }; + Ok(()) + } + + async fn replace_cached( + &self, + snapshot: HookImportStoreSnapshot, + ) -> Result<(), HookImportStoreError> { + let fingerprint = index_fingerprint(&self.root.join("index.json")).await?; + *self.state.write().await = CachedStore { + snapshot, + fingerprint, + }; + Ok(()) + } +} + +impl StoreRecordV1 { + fn from_write( + import_id: String, + bundle_digest: String, + content_digest: String, + enabled: bool, + write: &HookImportWrite, + ) -> Self { + Self { + import_id, + source: write.source.key.clone(), + ecosystem_id: write.source.ecosystem_id.clone(), + display_name: write.source.display_name.clone(), + source_kind: write.source.source_kind, + scope: write.source.scope, + location_hint: write.source.location_hint.clone(), + catalog_content_version: write.source.content_version.clone(), + behavior_version: write.behavior_version.clone(), + bundle_digest, + content_digest, + enabled, + } + } +} + +struct BundlePublication { + root: PathBuf, + final_path: PathBuf, + retired_path: Option, + changed: bool, +} + +impl BundlePublication { + async fn rollback(self) -> Result<(), HookImportStoreError> { + if !self.changed { + return Ok(()); + } + remove_owned_path(&self.root, &self.final_path).await?; + if let Some(retired_path) = self.retired_path { + tokio::fs::rename(retired_path, self.final_path) + .await + .map_err(io_error)?; + } + Ok(()) + } + + async fn finalize(self) { + if let Some(retired_path) = self.retired_path { + let _ = remove_owned_path(&self.root, &retired_path).await; + } + } +} + +async fn publish_bundle( + root: &Path, + final_path: &Path, + write: &HookImportWrite, + content_digest: &str, +) -> Result { + if tokio::fs::symlink_metadata(final_path).await.is_ok() { + if validate_bundle_content(root, final_path, content_digest) + .await + .is_ok() + { + return Ok(BundlePublication { + root: root.to_path_buf(), + final_path: final_path.to_path_buf(), + retired_path: None, + changed: false, + }); + } + } + let staging = root + .join(".staging") + .join(format!("import-{}", uuid::Uuid::new_v4())); + ensure_owned_directory(root, &staging).await?; + let result = async { + tokio::fs::write(staging.join("hooks.json"), &write.hooks_json) + .await + .map_err(|error| HookImportStoreError::Io(error.to_string()))?; + for asset in &write.assets { + let target = staging.join(&asset.relative_path); + if let Some(parent) = target.parent() { + ensure_owned_directory(&staging, parent).await?; + } + tokio::fs::write(target, &asset.bytes) + .await + .map_err(|error| HookImportStoreError::Io(error.to_string()))?; + } + validate_bundle_content(root, &staging, content_digest).await?; + let parent = final_path + .parent() + .ok_or(HookImportStoreError::InvalidInput("bundle path"))?; + ensure_owned_directory(root, parent).await?; + let retired = if tokio::fs::symlink_metadata(final_path).await.is_ok() { + validate_owned_directory(root, final_path).await?; + let retired = root + .join(".staging") + .join(format!("retired-{}", uuid::Uuid::new_v4())); + tokio::fs::rename(final_path, &retired) + .await + .map_err(io_error)?; + Some(retired) + } else { + None + }; + match tokio::fs::rename(&staging, final_path).await { + Ok(()) => Ok(BundlePublication { + root: root.to_path_buf(), + final_path: final_path.to_path_buf(), + retired_path: retired, + changed: true, + }), + Err(error) => { + if let Some(retired) = &retired { + tokio::fs::rename(retired, final_path).await.map_err(|restore| { + HookImportStoreError::Io(format!( + "bundle publication failed ({error}); restoring the indexed bundle failed ({restore})" + )) + })?; + } else if tokio::fs::symlink_metadata(final_path).await.is_ok() { + validate_bundle_content(root, final_path, content_digest).await?; + return Ok(BundlePublication { + root: root.to_path_buf(), + final_path: final_path.to_path_buf(), + retired_path: None, + changed: false, + }); + } + Err(HookImportStoreError::Io(error.to_string())) + } + } + } + .await; + if result.is_err() { + let _ = remove_owned_path(root, &staging).await; + } + result +} + +async fn validate_bundle_content( + root: &Path, + path: &Path, + expected_digest: &str, +) -> Result<(), HookImportStoreError> { + read_verified_bundle(root, path, expected_digest) + .await + .map(drop) +} + +async fn read_verified_bundle( + root: &Path, + path: &Path, + expected_digest: &str, +) -> Result, HookImportStoreError> { + validate_owned_directory(root, path).await?; + let mut entries = tokio::fs::read_dir(path).await.map_err(io_error)?; + while let Some(entry) = entries.next_entry().await.map_err(io_error)? { + let name = entry.file_name(); + if name != "hooks.json" && name != "hooks" { + return Err(HookImportStoreError::InvalidInput("bundle entry")); + } + let metadata = tokio::fs::symlink_metadata(entry.path()) + .await + .map_err(io_error)?; + if is_unsupported_link(&metadata) + || (name == "hooks.json" && !metadata.is_file()) + || (name == "hooks" && !metadata.is_dir()) + { + return Err(HookImportStoreError::InvalidInput("bundle entry")); + } + } + let hooks_json = read_bounded(&path.join("hooks.json"), MAX_HOOKS_FILE_BYTES as u64) + .await? + .ok_or(HookImportStoreError::InvalidInput("bundle hooks missing"))?; + validate_native_hooks(&hooks_json)?; + let assets = read_bundle_assets(root, path).await?; + let observed = content_digest_from_parts(&hooks_json, &assets); + if observed != expected_digest { + return Err(HookImportStoreError::InvalidInput("bundle content digest")); + } + Ok(hooks_json) +} + +async fn read_bundle_assets( + root: &Path, + bundle: &Path, +) -> Result, HookImportStoreError> { + let assets_root = bundle.join("hooks"); + let metadata = match tokio::fs::symlink_metadata(&assets_root).await { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(io_error(error)), + }; + if !metadata.is_dir() || is_unsupported_link(&metadata) { + return Err(HookImportStoreError::InvalidInput("bundle assets")); + } + validate_owned_directory(root, &assets_root).await?; + let mut pending = vec![assets_root]; + let mut assets = Vec::new(); + let mut total_bytes = 0usize; + while let Some(directory) = pending.pop() { + let mut entries = tokio::fs::read_dir(&directory).await.map_err(io_error)?; + while let Some(entry) = entries.next_entry().await.map_err(io_error)? { + let path = entry.path(); + let relative_path = path + .strip_prefix(bundle) + .map_err(|_| HookImportStoreError::InvalidInput("bundle asset path"))? + .to_path_buf(); + let depth = relative_path.components().count(); + if depth > MAX_EXTERNAL_HOOK_IMPORT_ASSET_DEPTH + || relative_path + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + return Err(HookImportStoreError::InvalidInput("bundle asset path")); + } + let metadata = tokio::fs::symlink_metadata(&path).await.map_err(io_error)?; + if is_unsupported_link(&metadata) { + return Err(HookImportStoreError::InvalidInput("bundle asset link")); + } + if metadata.is_dir() { + pending.push(path); + continue; + } + if !metadata.is_file() || assets.len() >= MAX_EXTERNAL_HOOK_IMPORT_ASSETS { + return Err(HookImportStoreError::InvalidInput("bundle asset")); + } + let bytes = read_bounded(&path, MAX_EXTERNAL_HOOK_IMPORT_ASSET_BYTES as u64) + .await? + .ok_or(HookImportStoreError::InvalidInput("bundle asset"))?; + total_bytes = total_bytes + .checked_add(bytes.len()) + .ok_or(HookImportStoreError::InvalidInput("bundle asset bytes"))?; + if total_bytes > MAX_EXTERNAL_HOOK_IMPORT_TOTAL_ASSET_BYTES { + return Err(HookImportStoreError::InvalidInput("bundle asset bytes")); + } + assets.push(PreparedExternalHookAsset { + relative_path, + bytes, + }); + } + } + Ok(assets) +} + +async fn verified_snapshot_from_index( + root: &Path, + scope: ExternalSourceScope, + index: &StoreIndexV1, +) -> Result { + let mut snapshot = snapshot_from_index(root, scope, index)?; + verify_snapshot_bundles(root, &mut snapshot).await; + Ok(snapshot) +} + +async fn verify_snapshot_bundles(root: &Path, snapshot: &mut HookImportStoreSnapshot) { + for record in &mut snapshot.imports { + record.bundle_valid = + validate_bundle_content(root, &record.bundle_path, &record.content_digest) + .await + .is_ok(); + } +} + +fn bundle_content_digest(write: &HookImportWrite) -> String { + content_digest_from_parts(&write.hooks_json, &write.assets) +} + +fn content_digest_from_parts(hooks_json: &[u8], assets: &[PreparedExternalHookAsset]) -> String { + let mut ordered = assets.iter().collect::>(); + ordered.sort_by(|left, right| left.relative_path.cmp(&right.relative_path)); + let mut hasher = Sha256::new(); + digest_part(&mut hasher, b"bitfun-hook-import-bundle-v1"); + digest_part(&mut hasher, hooks_json); + for asset in ordered { + digest_part( + &mut hasher, + asset + .relative_path + .to_string_lossy() + .replace('\\', "/") + .as_bytes(), + ); + digest_part(&mut hasher, &asset.bytes); + } + hex::encode(hasher.finalize()) +} + +fn digest_part(hasher: &mut Sha256, value: &[u8]) { + hasher.update((value.len() as u64).to_be_bytes()); + hasher.update(value); +} + +async fn validate_store_root(root: &Path) -> Result<(), HookImportStoreError> { + match tokio::fs::symlink_metadata(root).await { + Ok(metadata) if metadata.is_dir() && !is_unsupported_link(&metadata) => Ok(()), + Ok(_) => Err(HookImportStoreError::InvalidInput("store root")), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(io_error(error)), + } +} + +async fn ensure_store_root(root: &Path) -> Result<(), HookImportStoreError> { + tokio::fs::create_dir_all(root).await.map_err(io_error)?; + validate_store_root(root).await +} + +async fn ensure_owned_directory(root: &Path, directory: &Path) -> Result<(), HookImportStoreError> { + ensure_store_root(root).await?; + walk_owned_directory(root, directory, true).await +} + +async fn validate_owned_directory( + root: &Path, + directory: &Path, +) -> Result<(), HookImportStoreError> { + validate_store_root(root).await?; + walk_owned_directory(root, directory, false).await +} + +async fn walk_owned_directory( + root: &Path, + directory: &Path, + create: bool, +) -> Result<(), HookImportStoreError> { + let relative = directory + .strip_prefix(root) + .map_err(|_| HookImportStoreError::InvalidInput("managed directory"))?; + let mut current = root.to_path_buf(); + for component in relative.components() { + let Component::Normal(component) = component else { + return Err(HookImportStoreError::InvalidInput("managed directory")); + }; + current.push(component); + if create { + match tokio::fs::create_dir(¤t).await { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(error) => return Err(io_error(error)), + } + } + let metadata = tokio::fs::symlink_metadata(¤t) + .await + .map_err(io_error)?; + if !metadata.is_dir() || is_unsupported_link(&metadata) { + return Err(HookImportStoreError::InvalidInput("managed directory")); + } + } + Ok(()) +} + +fn is_unsupported_link(metadata: &std::fs::Metadata) -> bool { + metadata.file_type().is_symlink() || is_windows_reparse_point(metadata) +} + +#[cfg(windows)] +fn is_windows_reparse_point(metadata: &std::fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt; + metadata.file_attributes() & 0x400 != 0 +} + +#[cfg(not(windows))] +fn is_windows_reparse_point(_metadata: &std::fs::Metadata) -> bool { + false +} + +fn validate_native_hooks(bytes: &[u8]) -> Result<(), HookImportStoreError> { + let layer = AgentHookSettingsLayer { + scope: AgentHookScope::User, + source: "managed-hook-import".to_string(), + bytes: bytes.to_vec(), + }; + let (settings, issues) = AgentHookSettings::from_layers(&[layer]); + if !issues.is_empty() || settings.is_empty() { + return Err(HookImportStoreError::InvalidInput("native Hook document")); + } + Ok(()) +} + +fn validate_write( + scope: ExternalSourceScope, + write: &HookImportWrite, +) -> Result<(), HookImportStoreError> { + write + .source + .validate() + .map_err(|_| HookImportStoreError::InvalidInput("source"))?; + if !scope_accepts(scope, write.source.scope) { + return Err(HookImportStoreError::InvalidInput("source scope")); + } + validate_native_hooks(&write.hooks_json)?; + if write.assets.len() > MAX_EXTERNAL_HOOK_IMPORT_ASSETS { + return Err(HookImportStoreError::InvalidInput("asset count")); + } + let mut paths = BTreeSet::new(); + let mut total_bytes = 0usize; + for asset in &write.assets { + if asset.relative_path.is_absolute() + || asset.relative_path.components().count() > MAX_EXTERNAL_HOOK_IMPORT_ASSET_DEPTH + || asset + .relative_path + .components() + .any(|component| !matches!(component, Component::Normal(_))) + || asset.bytes.len() > MAX_EXTERNAL_HOOK_IMPORT_ASSET_BYTES + || !paths.insert(asset.relative_path.clone()) + { + return Err(HookImportStoreError::InvalidInput("asset")); + } + total_bytes = total_bytes + .checked_add(asset.bytes.len()) + .ok_or(HookImportStoreError::InvalidInput("asset bytes"))?; + } + if total_bytes > MAX_EXTERNAL_HOOK_IMPORT_TOTAL_ASSET_BYTES { + return Err(HookImportStoreError::InvalidInput("asset bytes")); + } + Ok(()) +} + +fn validate_index( + scope: ExternalSourceScope, + index: &StoreIndexV1, +) -> Result<(), HookImportStoreError> { + if index.schema_version != INDEX_SCHEMA_V1 || index.imports.len() > 2048 { + return Err(HookImportStoreError::Corrupt); + } + let mut ids = BTreeSet::new(); + for record in &index.imports { + if !scope_accepts(scope, record.scope) + || record.import_id != import_id(&record.source) + || !ids.insert(&record.import_id) + || !safe_component(&record.import_id) + || !safe_component(&record.bundle_digest) + || !safe_component(&record.content_digest) + { + return Err(HookImportStoreError::Corrupt); + } + } + Ok(()) +} + +fn snapshot_from_loaded( + root: &Path, + scope: ExternalSourceScope, + loaded: &LoadedIndex, +) -> Result { + match loaded { + LoadedIndex::Missing => Ok(HookImportStoreSnapshot::default()), + LoadedIndex::Ready(index) => match snapshot_from_index(root, scope, index) { + Ok(snapshot) => Ok(snapshot), + Err(HookImportStoreError::Corrupt) => Ok(HookImportStoreSnapshot { + corrupt_marker: Some(invalid_index_marker(index)), + ..HookImportStoreSnapshot::default() + }), + Err(error) => Err(error), + }, + LoadedIndex::Corrupt(marker) => Ok(HookImportStoreSnapshot { + corrupt_marker: Some(marker.clone()), + ..HookImportStoreSnapshot::default() + }), + } +} + +fn invalid_index_marker(index: &StoreIndexV1) -> String { + let bytes = serde_json::to_vec(index).unwrap_or_default(); + format!("sha256:{}", hex::encode(Sha256::digest(&bytes))) +} + +fn snapshot_from_index( + root: &Path, + scope: ExternalSourceScope, + index: &StoreIndexV1, +) -> Result { + validate_index(scope, index)?; + Ok(HookImportStoreSnapshot { + generation: index.generation, + imports: index + .imports + .iter() + .map(|record| HookImportRecord { + import_id: record.import_id.clone(), + source: ExternalHookSource { + key: record.source.clone(), + ecosystem_id: record.ecosystem_id.clone(), + display_name: record.display_name.clone(), + source_kind: record.source_kind, + scope: record.scope, + location_hint: record.location_hint.clone(), + health: ExternalSourceHealth::Available, + content_version: record.catalog_content_version.clone(), + diagnostics: Vec::new(), + }, + enabled: record.enabled, + behavior_version: record.behavior_version.clone(), + bundle_path: root + .join("bundles") + .join(&record.import_id) + .join(&record.bundle_digest), + content_digest: record.content_digest.clone(), + bundle_valid: false, + }) + .collect(), + corrupt_marker: None, + }) +} + +async fn load_index(path: &Path) -> Result { + let metadata = match tokio::fs::symlink_metadata(path).await { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(LoadedIndex::Missing) + } + Err(error) => return Err(HookImportStoreError::Io(error.to_string())), + }; + if !metadata.is_file() || is_unsupported_link(&metadata) || metadata.len() > MAX_INDEX_BYTES { + return Ok(LoadedIndex::Corrupt(format!( + "sha256:{}", + hex::encode(Sha256::digest( + format!("invalid:{}", metadata.len()).as_bytes() + )) + ))); + } + let bytes = tokio::fs::read(path) + .await + .map_err(|error| HookImportStoreError::Io(error.to_string()))?; + match serde_json::from_slice::(&bytes) { + Ok(index) => Ok(LoadedIndex::Ready(index)), + Err(_) => Ok(LoadedIndex::Corrupt(format!( + "sha256:{}", + hex::encode(Sha256::digest(&bytes)) + ))), + } +} + +async fn read_bounded( + path: &Path, + max_bytes: u64, +) -> Result>, HookImportStoreError> { + let metadata = match tokio::fs::symlink_metadata(path).await { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(HookImportStoreError::Io(error.to_string())), + }; + if !metadata.is_file() || is_unsupported_link(&metadata) || metadata.len() > max_bytes { + return Ok(None); + } + let bytes = tokio::fs::read(path) + .await + .map_err(|error| HookImportStoreError::Io(error.to_string()))?; + (bytes.len() as u64 <= max_bytes) + .then_some(bytes) + .map(Some) + .ok_or(HookImportStoreError::InvalidInput("file grew past budget")) +} + +async fn index_fingerprint(path: &Path) -> Result, HookImportStoreError> { + match tokio::fs::symlink_metadata(path).await { + Ok(metadata) => Ok(Some(IndexFingerprint { + len: metadata.len(), + modified_nanos: metadata + .modified() + .ok() + .and_then(|value| value.duration_since(UNIX_EPOCH).ok()) + .map_or(0, |value| value.as_nanos()), + })), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(HookImportStoreError::Io(error.to_string())), + } +} + +async fn remove_owned_path(root: &Path, path: &Path) -> Result<(), HookImportStoreError> { + let parent = path + .parent() + .ok_or(HookImportStoreError::InvalidInput("managed path"))?; + validate_owned_directory(root, parent).await?; + let metadata = match tokio::fs::symlink_metadata(path).await { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(io_error(error)), + }; + if is_unsupported_link(&metadata) { + return Err(HookImportStoreError::InvalidInput("managed path link")); + } + if !metadata.is_dir() { + tokio::fs::remove_file(path).await.map_err(io_error) + } else { + tokio::fs::remove_dir_all(path).await.map_err(io_error) + } +} + +fn bundle_digest(write: &HookImportWrite) -> String { + behavior_directory(&write.behavior_version) +} + +fn behavior_directory(behavior_version: &str) -> String { + hex::encode(Sha256::digest(behavior_version.as_bytes())) +} + +fn reset_generation() -> u64 { + let value = uuid::Uuid::new_v4().as_u128() as u64 & (u64::MAX >> 1); + value.max(1) +} + +fn import_id(source: &SourceKey) -> String { + format!( + "hook-{}", + &hex::encode(Sha256::digest(source.stable_key().as_bytes()))[..24] + ) +} + +fn safe_component(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && value + .bytes() + .all(|value| value.is_ascii_alphanumeric() || value == b'-') +} + +fn hook_scope(scope: ExternalSourceScope) -> AgentHookScope { + match scope { + ExternalSourceScope::UserGlobal => AgentHookScope::User, + _ => AgentHookScope::Project, + } +} + +fn scope_accepts(store_scope: ExternalSourceScope, source_scope: ExternalSourceScope) -> bool { + match store_scope { + ExternalSourceScope::UserGlobal => source_scope == ExternalSourceScope::UserGlobal, + ExternalSourceScope::Project | ExternalSourceScope::WorkspaceLocal => matches!( + source_scope, + ExternalSourceScope::Project | ExternalSourceScope::WorkspaceLocal + ), + _ => source_scope == store_scope, + } +} + +fn io_error(error: impl std::fmt::Display) -> HookImportStoreError { + HookImportStoreError::Io(error.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use bitfun_product_domains::external_hook_catalog::{ + ExternalHookSource, ExternalHookSourceKind, + }; + use bitfun_product_domains::external_hook_import::PreparedExternalHookAsset; + use bitfun_product_domains::external_sources::{ + EcosystemId, ExternalSourceHealth, ExternalSourceScope, SourceKey, + }; + use tempfile::tempdir; + + fn source(scope: ExternalSourceScope) -> ExternalHookSource { + ExternalHookSource { + key: SourceKey::new("codex.hooks", "user-hooks-json").unwrap(), + ecosystem_id: EcosystemId::new("codex").unwrap(), + display_name: "Codex user hooks".to_string(), + source_kind: ExternalHookSourceKind::HooksFile, + scope, + location_hint: "~/.codex/hooks.json".to_string(), + health: ExternalSourceHealth::Available, + content_version: "sha256:catalog".to_string(), + diagnostics: Vec::new(), + } + } + + fn write(scope: ExternalSourceScope, command: &str) -> HookImportWrite { + HookImportWrite { + source: source(scope), + behavior_version: format!("sha256:{command}"), + hooks_json: format!( + r#"{{"hooks":{{"PreToolUse":[{{"hooks":[{{"type":"command","command":"{command}"}}]}}]}}}}"# + ) + .into_bytes(), + assets: vec![PreparedExternalHookAsset { + relative_path: "hooks/check.py".into(), + bytes: b"print('ok')".to_vec(), + }], + } + } + + #[tokio::test] + async fn apply_is_generation_fenced_idempotent_and_restart_safe() { + let temp = tempdir().unwrap(); + let root = temp.path().join("hook-imports"); + let store = HookImportStore::open(root.clone(), ExternalSourceScope::UserGlobal) + .await + .unwrap(); + assert_eq!(store.snapshot().await.unwrap().generation, 0); + + let applied = store + .apply(0, write(ExternalSourceScope::UserGlobal, "check")) + .await + .unwrap(); + assert!(matches!(applied, HookImportApply::Applied)); + let snapshot = store.snapshot().await.unwrap(); + assert_eq!(snapshot.generation, 1); + assert_eq!(snapshot.imports.len(), 1); + assert_eq!(store.enabled_layers().await.unwrap().len(), 1); + + let unchanged = store + .apply(1, write(ExternalSourceScope::UserGlobal, "check")) + .await + .unwrap(); + assert!(matches!(unchanged, HookImportApply::Unchanged)); + assert_eq!(store.snapshot().await.unwrap().generation, 1); + + let reopened = HookImportStore::open(root, ExternalSourceScope::UserGlobal) + .await + .unwrap(); + assert_eq!(reopened.snapshot().await.unwrap().generation, 1); + assert_eq!(reopened.enabled_layers().await.unwrap().len(), 1); + } + + #[tokio::test] + async fn corrupt_index_fails_closed_until_explicit_reset() { + let temp = tempdir().unwrap(); + let root = temp.path().join("hook-imports"); + tokio::fs::create_dir_all(&root).await.unwrap(); + tokio::fs::write(root.join("index.json"), b"{not-json") + .await + .unwrap(); + let store = HookImportStore::open(root.clone(), ExternalSourceScope::UserGlobal) + .await + .unwrap(); + let corrupt = store.snapshot().await.unwrap(); + assert!(corrupt.corrupt_marker.is_some()); + assert!(store.enabled_layers().await.unwrap().is_empty()); + assert!(matches!( + store + .apply(0, write(ExternalSourceScope::UserGlobal, "check")) + .await, + Err(HookImportStoreError::Corrupt) + )); + + let reset = store.reset_corrupt().await.unwrap(); + assert_ne!(reset.generation, 0); + assert!(reset.corrupt_marker.is_none()); + assert!(tokio::fs::metadata(root.join("index.json")).await.is_ok()); + assert!(matches!( + store + .apply(0, write(ExternalSourceScope::UserGlobal, "check")) + .await, + Err(HookImportStoreError::StaleGeneration) + )); + } + + #[tokio::test] + async fn stale_generation_and_invalid_scope_never_publish() { + let temp = tempdir().unwrap(); + let store = HookImportStore::open( + temp.path().join("hook-imports"), + ExternalSourceScope::UserGlobal, + ) + .await + .unwrap(); + assert!(matches!( + store + .apply(1, write(ExternalSourceScope::UserGlobal, "check")) + .await, + Err(HookImportStoreError::StaleGeneration) + )); + assert!(matches!( + store + .apply(0, write(ExternalSourceScope::Project, "check")) + .await, + Err(HookImportStoreError::InvalidInput(_)) + )); + assert_eq!(store.snapshot().await.unwrap().generation, 0); + } + + #[tokio::test] + async fn missing_active_bundle_is_repaired_instead_of_reported_unchanged() { + let temp = tempdir().unwrap(); + let store = HookImportStore::open( + temp.path().join("hook-imports"), + ExternalSourceScope::UserGlobal, + ) + .await + .unwrap(); + store + .apply(0, write(ExternalSourceScope::UserGlobal, "check")) + .await + .unwrap(); + let snapshot = store.snapshot().await.unwrap(); + tokio::fs::remove_dir_all(&snapshot.imports[0].bundle_path) + .await + .unwrap(); + + let repaired = store + .apply(1, write(ExternalSourceScope::UserGlobal, "check")) + .await + .unwrap(); + assert!(matches!(repaired, HookImportApply::Applied)); + assert_eq!(store.enabled_layers().await.unwrap().len(), 1); + } + + #[tokio::test] + async fn valid_but_modified_bundle_is_rejected_and_repaired() { + let temp = tempdir().unwrap(); + let store = HookImportStore::open( + temp.path().join("hook-imports"), + ExternalSourceScope::UserGlobal, + ) + .await + .unwrap(); + store + .apply(0, write(ExternalSourceScope::UserGlobal, "check")) + .await + .unwrap(); + let snapshot = store.snapshot().await.unwrap(); + let bundle = &snapshot.imports[0].bundle_path; + tokio::fs::write( + bundle.join("hooks.json"), + br#"{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"unreviewed"}]}]}}"#, + ) + .await + .unwrap(); + + let reopened = HookImportStore::open( + temp.path().join("hook-imports"), + ExternalSourceScope::UserGlobal, + ) + .await + .unwrap(); + assert!(reopened.enabled_layers().await.unwrap().is_empty()); + + let repaired = reopened + .apply(1, write(ExternalSourceScope::UserGlobal, "check")) + .await + .unwrap(); + assert!(matches!(repaired, HookImportApply::Applied)); + assert_eq!(reopened.enabled_layers().await.unwrap().len(), 1); + + tokio::fs::write(bundle.join("hooks/check.py"), b"print('unreviewed')") + .await + .unwrap(); + let reopened_after_asset_change = HookImportStore::open( + temp.path().join("hook-imports"), + ExternalSourceScope::UserGlobal, + ) + .await + .unwrap(); + assert!(reopened_after_asset_change + .enabled_layers() + .await + .unwrap() + .is_empty()); + } + + #[tokio::test] + async fn same_process_bundle_change_is_rejected_before_runtime_load() { + let temp = tempdir().unwrap(); + let store = HookImportStore::open( + temp.path().join("hook-imports"), + ExternalSourceScope::UserGlobal, + ) + .await + .unwrap(); + store + .apply(0, write(ExternalSourceScope::UserGlobal, "check")) + .await + .unwrap(); + let snapshot = store.snapshot().await.unwrap(); + tokio::fs::write( + snapshot.imports[0].bundle_path.join("hooks.json"), + br#"{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"unreviewed"}]}]}}"#, + ) + .await + .unwrap(); + + assert!(store.enabled_layers().await.unwrap().is_empty()); + } + + #[tokio::test] + async fn failed_same_path_repair_preserves_the_indexed_bundle() { + let temp = tempdir().unwrap(); + let root = temp.path().join("hook-imports"); + let store = HookImportStore::open(root.clone(), ExternalSourceScope::UserGlobal) + .await + .unwrap(); + let original = write(ExternalSourceScope::UserGlobal, "check"); + store.apply(0, original.clone()).await.unwrap(); + let snapshot = store.snapshot().await.unwrap(); + let bundle_path = snapshot.imports[0].bundle_path.clone(); + tokio::fs::remove_dir(root.join(".staging")).await.unwrap(); + tokio::fs::write(root.join(".staging"), b"not-a-directory") + .await + .unwrap(); + let mut changed = original; + changed.hooks_json = + br#"{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"changed"}]}]}}"# + .to_vec(); + + assert!(store.apply(1, changed).await.is_err()); + assert!(tokio::fs::metadata(bundle_path.join("hooks.json")) + .await + .is_ok()); + assert_eq!(store.enabled_layers().await.unwrap().len(), 1); + } + + #[tokio::test] + async fn same_path_publication_can_rollback_before_index_commit() { + let temp = tempdir().unwrap(); + let root = temp.path().join("hook-imports"); + let store = HookImportStore::open(root.clone(), ExternalSourceScope::UserGlobal) + .await + .unwrap(); + let original = write(ExternalSourceScope::UserGlobal, "check"); + store.apply(0, original.clone()).await.unwrap(); + let bundle_path = store.snapshot().await.unwrap().imports[0] + .bundle_path + .clone(); + let original_bytes = tokio::fs::read(bundle_path.join("hooks.json")) + .await + .unwrap(); + let mut changed = original; + changed.hooks_json = + br#"{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"changed"}]}]}}"# + .to_vec(); + let changed_digest = bundle_content_digest(&changed); + + let publication = publish_bundle(&root, &bundle_path, &changed, &changed_digest) + .await + .unwrap(); + assert_ne!( + tokio::fs::read(bundle_path.join("hooks.json")) + .await + .unwrap(), + original_bytes + ); + publication.rollback().await.unwrap(); + + assert_eq!( + tokio::fs::read(bundle_path.join("hooks.json")) + .await + .unwrap(), + original_bytes + ); + } + + #[tokio::test] + async fn successful_same_path_publication_removes_retired_bundle() { + let temp = tempdir().unwrap(); + let root = temp.path().join("hook-imports"); + let store = HookImportStore::open(root.clone(), ExternalSourceScope::UserGlobal) + .await + .unwrap(); + let mut changed = write(ExternalSourceScope::UserGlobal, "check"); + store.apply(0, changed.clone()).await.unwrap(); + changed.hooks_json = + br#"{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"changed"}]}]}}"# + .to_vec(); + + store.apply(1, changed).await.unwrap(); + + let mut staging = tokio::fs::read_dir(root.join(".staging")).await.unwrap(); + assert!(staging.next_entry().await.unwrap().is_none()); + } + + #[tokio::test] + async fn remove_deletes_only_the_removed_digest_directory() { + let temp = tempdir().unwrap(); + let store = HookImportStore::open( + temp.path().join("hook-imports"), + ExternalSourceScope::UserGlobal, + ) + .await + .unwrap(); + store + .apply(0, write(ExternalSourceScope::UserGlobal, "check")) + .await + .unwrap(); + let snapshot = store.snapshot().await.unwrap(); + let import_root = snapshot.imports[0].bundle_path.parent().unwrap(); + let concurrent_bundle = import_root.join("concurrent-reimport"); + tokio::fs::create_dir_all(&concurrent_bundle).await.unwrap(); + let import_id = snapshot.imports[0].import_id.clone(); + + store.remove(1, &import_id).await.unwrap(); + + assert!(tokio::fs::metadata(&concurrent_bundle).await.is_ok()); + } + + #[cfg(any(unix, windows))] + #[tokio::test] + async fn managed_bundle_ancestors_cannot_be_links_or_reparse_points() { + let temp = tempdir().unwrap(); + let root = temp.path().join("hook-imports"); + let outside = temp.path().join("outside"); + tokio::fs::create_dir_all(&root).await.unwrap(); + tokio::fs::create_dir_all(&outside).await.unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink(&outside, root.join("bundles")).unwrap(); + #[cfg(windows)] + assert!(std::process::Command::new("cmd") + .args(["/c", "mklink", "/J"]) + .arg(root.join("bundles")) + .arg(&outside) + .output() + .expect("create junction") + .status + .success()); + let store = HookImportStore::open(root, ExternalSourceScope::UserGlobal) + .await + .unwrap(); + + assert!(store + .apply(0, write(ExternalSourceScope::UserGlobal, "check")) + .await + .is_err()); + assert!(tokio::fs::read_dir(&outside) + .await + .unwrap() + .next_entry() + .await + .unwrap() + .is_none()); + } + + #[tokio::test] + async fn missing_bundle_cannot_be_reenabled_without_repair() { + let temp = tempdir().unwrap(); + let store = HookImportStore::open( + temp.path().join("hook-imports"), + ExternalSourceScope::UserGlobal, + ) + .await + .unwrap(); + store + .apply(0, write(ExternalSourceScope::UserGlobal, "check")) + .await + .unwrap(); + let import_id = + HookImportStore::stable_import_id(&source(ExternalSourceScope::UserGlobal).key); + let disabled = store.set_enabled(1, &import_id, false).await.unwrap(); + tokio::fs::remove_dir_all(&disabled.imports[0].bundle_path) + .await + .unwrap(); + + assert!(matches!( + store.set_enabled(2, &import_id, true).await, + Err(HookImportStoreError::InvalidInput( + "bundle missing or invalid" + )) + )); + let unchanged = store.snapshot().await.unwrap(); + assert_eq!(unchanged.generation, 2); + assert!(!unchanged.imports[0].enabled); + } + + #[tokio::test] + async fn management_ids_cannot_escape_the_managed_bundle_root() { + let temp = tempdir().unwrap(); + let store = HookImportStore::open( + temp.path().join("hook-imports"), + ExternalSourceScope::UserGlobal, + ) + .await + .unwrap(); + assert!(matches!( + store.remove(0, "../outside").await, + Err(HookImportStoreError::InvalidInput("import id")) + )); + } +} diff --git a/src/crates/services/services-integrations/src/lib.rs b/src/crates/services/services-integrations/src/lib.rs index 1af57d16ca..0e1f251b3c 100644 --- a/src/crates/services/services-integrations/src/lib.rs +++ b/src/crates/services/services-integrations/src/lib.rs @@ -27,6 +27,9 @@ pub mod function_agents; #[cfg(feature = "git")] pub mod git; +#[cfg(feature = "hook-import")] +pub mod hook_import; + #[cfg(feature = "mcp")] pub mod mcp; 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 index c517a12c59..bf465f2149 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ExternalHooksAPI.test.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ExternalHooksAPI.test.ts @@ -44,6 +44,38 @@ const validSnapshot = { diagnostics: [], }; +const validImportPlan = { + schemaVersion: 1, + source: validSnapshot.sources[0], + disposition: 'import', + behaviorVersion: 'sha256:behavior', + handlers: [{ + stableKey: 'pre-tool-use-0', + event: 'PreToolUse', + matcher: 'Bash', + command: 'python D:/managed/hooks/check.py', + commandWindows: 'py D:/managed/hooks/check.py', + timeoutSeconds: 30, + dependencies: [{ kind: 'managed', relativePath: 'hooks/check.py' }], + }], + skipped: [{ reasonCode: 'unsupported_event', count: 1 }], + planFingerprint: 'sha256:plan', +}; + +const validImportSnapshot = { + schemaVersion: 1, + revision: 'sha256:revision', + catalog: validSnapshot, + imports: [{ + importId: 'sha256:source', + source: validSnapshot.sources[0], + enabled: true, + behaviorVersion: 'sha256:behavior', + state: 'current', + }], + diagnostics: [], +}; + describe('ExternalHooksAPI', () => { beforeEach(() => invokeMock.mockReset()); @@ -179,4 +211,49 @@ describe('ExternalHooksAPI', () => { code: 'invalid_response', }); }); + + it('uses one shared plan for preview and explicit apply', async () => { + invokeMock.mockResolvedValueOnce(validImportPlan).mockResolvedValueOnce({ + schemaVersion: 1, + outcome: { kind: 'applied', snapshot: validImportSnapshot }, + }); + + const plan = await externalHooksAPI.planImport( + ' D:/workspace/project ', + validSnapshot.sources[0].key, + ); + await expect(externalHooksAPI.applyImport('D:/workspace/project', plan)).resolves.toMatchObject({ + outcome: { kind: 'applied' }, + }); + expect(invokeMock).toHaveBeenNthCalledWith(1, 'plan_external_hook_import_command', { + request: { + workspacePath: 'D:/workspace/project', + source: validSnapshot.sources[0].key, + }, + }); + expect(invokeMock).toHaveBeenNthCalledWith(2, 'apply_external_hook_import_command', { + request: { + workspacePath: 'D:/workspace/project', + importRequest: { + schemaVersion: 1, + source: validSnapshot.sources[0].key, + planFingerprint: 'sha256:plan', + }, + }, + }); + }); + + it('normalizes import snapshots and fails closed on unreviewed executable fields', async () => { + invokeMock.mockResolvedValueOnce(validImportSnapshot); + await expect(externalHooksAPI.getImportSnapshot(undefined, true)) + .resolves.toEqual(validImportSnapshot); + + invokeMock.mockResolvedValueOnce({ + ...validImportSnapshot, + imports: [{ ...validImportSnapshot.imports[0], command: 'not-reviewed' }], + }); + await expect(externalHooksAPI.getImportSnapshot()).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 index fba586e6a9..ef4d031ba8 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ExternalHooksAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ExternalHooksAPI.ts @@ -64,6 +64,60 @@ export interface ExternalHookCatalogSnapshot { diagnostics: ExternalHookDiagnostic[]; } +export type ExternalHookImportDependency = + | { kind: 'managed'; relativePath: string } + | { kind: 'external'; location: string }; + +export interface ExternalHookImportHandler { + stableKey: string; + event: string; + matcher?: string; + command: string; + commandWindows?: string; + timeoutSeconds?: number; + statusMessage?: string; + dependencies: ExternalHookImportDependency[]; +} + +export interface ExternalHookImportPlan { + schemaVersion: 1; + source: ExternalHookSource; + disposition: 'import' | 'update' | 'unchanged' | 'unavailable'; + behaviorVersion: string; + handlers: ExternalHookImportHandler[]; + skipped: Array<{ reasonCode: string; count: number }>; + planFingerprint: string; +} + +export interface ImportedHookSourceSnapshot { + importId: string; + source: ExternalHookSource; + enabled: boolean; + behaviorVersion: string; + state: 'current' | 'update_available' | 'source_missing' | 'update_check_failed' + | 'bundle_missing'; +} + +export interface ExternalHookImportSnapshot { + schemaVersion: 1; + revision: string; + catalog: ExternalHookCatalogSnapshot; + imports: ImportedHookSourceSnapshot[]; + diagnostics: ExternalHookDiagnostic[]; +} + +export type ExternalHookImportApplyResult = { + schemaVersion: 1; + outcome: + | { kind: 'applied' | 'unchanged'; snapshot: ExternalHookImportSnapshot } + | { kind: 'stale'; refreshedPlan: ExternalHookImportPlan }; +}; + +export type ExternalHookImportMutation = + | { kind: 'set_enabled'; importId: string; enabled: boolean } + | { kind: 'remove'; importId: string } + | { kind: 'reset_corrupt_store'; scope: ExternalHookSource['scope'] }; + const MAX_CATALOG_ITEMS = 8192; const MAX_PROVIDER_ITEMS = 2048; const MAX_SOURCE_DIAGNOSTICS = 256; @@ -355,6 +409,155 @@ function normalizeCatalog(value: unknown): ExternalHookCatalogSnapshot { }; } +function optionalString(value: unknown, maxLength: number): string | undefined { + return value === undefined ? undefined : boundedString(value, maxLength); +} + +function positiveInteger(value: unknown): number { + if (!Number.isSafeInteger(value) || (value as number) <= 0) invalidResponse(); + return value as number; +} + +function dependency(value: unknown): ExternalHookImportDependency { + const tagged = exactRecord(value, ['kind'], ['relativePath', 'location']); + if (tagged.kind === 'managed' && tagged.location === undefined) { + return { kind: 'managed', relativePath: boundedString(tagged.relativePath, 4096) }; + } + if (tagged.kind === 'external' && tagged.relativePath === undefined) { + return { kind: 'external', location: boundedString(tagged.location, 4096) }; + } + return invalidResponse(); +} + +function importHandler(value: unknown): ExternalHookImportHandler { + const record = exactRecord(value, ['stableKey', 'event', 'command'], [ + 'matcher', + 'commandWindows', + 'timeoutSeconds', + 'statusMessage', + 'dependencies', + ]); + const dependencies = record.dependencies === undefined + ? [] + : boundedArray(record.dependencies).map(dependency); + if (dependencies.length > 256) invalidResponse(); + const timeoutSeconds = record.timeoutSeconds === undefined + ? undefined + : positiveInteger(record.timeoutSeconds); + return { + stableKey: boundedString(record.stableKey, 160), + event: boundedString(record.event, 160), + command: boundedString(record.command, 65_536), + ...(optionalString(record.matcher, 512) === undefined + ? {} : { matcher: optionalString(record.matcher, 512) }), + ...(optionalString(record.commandWindows, 65_536) === undefined + ? {} : { commandWindows: optionalString(record.commandWindows, 65_536) }), + ...(timeoutSeconds === undefined ? {} : { timeoutSeconds }), + ...(optionalString(record.statusMessage, 4096) === undefined + ? {} : { statusMessage: optionalString(record.statusMessage, 4096) }), + dependencies, + }; +} + +function normalizeImportPlan(value: unknown): ExternalHookImportPlan { + const record = exactRecord(value, [ + 'schemaVersion', + 'source', + 'disposition', + 'behaviorVersion', + 'handlers', + 'skipped', + 'planFingerprint', + ]); + if (record.schemaVersion !== 1) invalidResponse(); + const handlers = boundedArray(record.handlers).map(importHandler); + const skipped = boundedArray(record.skipped).map((item) => { + const skippedItem = exactRecord(item, ['reasonCode', 'count']); + return { + reasonCode: boundedString(skippedItem.reasonCode, 160), + count: positiveInteger(skippedItem.count), + }; + }); + if (handlers.length > 2048 || skipped.length > 256) invalidResponse(); + return { + schemaVersion: 1, + source: source(record.source), + disposition: enumString(record.disposition, [ + 'import', + 'update', + 'unchanged', + 'unavailable', + ] as const), + behaviorVersion: boundedString(record.behaviorVersion, 160), + handlers, + skipped, + planFingerprint: boundedString(record.planFingerprint, 160), + }; +} + +function importedSource(value: unknown): ImportedHookSourceSnapshot { + const record = exactRecord(value, [ + 'importId', + 'source', + 'enabled', + 'behaviorVersion', + 'state', + ]); + if (typeof record.enabled !== 'boolean') invalidResponse(); + return { + importId: boundedString(record.importId, 160), + source: source(record.source), + enabled: record.enabled, + behaviorVersion: boundedString(record.behaviorVersion, 160), + state: enumString(record.state, [ + 'current', + 'update_available', + 'source_missing', + 'update_check_failed', + 'bundle_missing', + ] as const), + }; +} + +function normalizeImportSnapshot(value: unknown): ExternalHookImportSnapshot { + const record = exactRecord(value, [ + 'schemaVersion', + 'revision', + 'catalog', + 'imports', + ], ['diagnostics']); + if (record.schemaVersion !== 1) invalidResponse(); + const imports = boundedArray(record.imports).map(importedSource); + if (imports.length > 4096) invalidResponse(); + return { + schemaVersion: 1, + revision: boundedString(record.revision, 160), + catalog: normalizeCatalog(record.catalog), + imports, + diagnostics: diagnostics(record.diagnostics), + }; +} + +function normalizeApplyResult(value: unknown): ExternalHookImportApplyResult { + const record = exactRecord(value, ['schemaVersion', 'outcome']); + if (record.schemaVersion !== 1) invalidResponse(); + const outcome = exactRecord(record.outcome, ['kind'], ['snapshot', 'refreshedPlan']); + if ((outcome.kind === 'applied' || outcome.kind === 'unchanged') + && outcome.refreshedPlan === undefined) { + return { + schemaVersion: 1, + outcome: { kind: outcome.kind, snapshot: normalizeImportSnapshot(outcome.snapshot) }, + }; + } + if (outcome.kind === 'stale' && outcome.snapshot === undefined) { + return { + schemaVersion: 1, + outcome: { kind: 'stale', refreshedPlan: normalizeImportPlan(outcome.refreshedPlan) }, + }; + } + return invalidResponse(); +} + export const externalHooksAPI = { async getCatalog(workspacePath?: string, forceRefresh = false) { const response = await invokeExternalSourceCommand('get_external_hook_catalog', { @@ -365,4 +568,60 @@ export const externalHooksAPI = { }); return normalizeCatalog(response); }, + async getImportSnapshot(workspacePath?: string, refreshUpdates = false) { + const response = await invokeExternalSourceCommand( + 'get_external_hook_import_snapshot', + { + request: { + workspacePath: normalizeOptionalWorkspacePath(workspacePath), + refreshUpdates, + }, + }, + ); + return normalizeImportSnapshot(response); + }, + async planImport(workspacePath: string | undefined, sourceKey: ExternalHookSourceKey) { + const response = await invokeExternalSourceCommand( + 'plan_external_hook_import_command', + { + request: { + workspacePath: normalizeOptionalWorkspacePath(workspacePath), + source: sourceKey, + }, + }, + ); + return normalizeImportPlan(response); + }, + async applyImport(workspacePath: string | undefined, plan: ExternalHookImportPlan) { + const response = await invokeExternalSourceCommand( + 'apply_external_hook_import_command', + { + request: { + workspacePath: normalizeOptionalWorkspacePath(workspacePath), + importRequest: { + schemaVersion: 1, + source: plan.source.key, + planFingerprint: plan.planFingerprint, + }, + }, + }, + ); + return normalizeApplyResult(response); + }, + async mutateImport( + workspacePath: string | undefined, + expectedRevision: string, + action: ExternalHookImportMutation, + ) { + const response = await invokeExternalSourceCommand( + 'mutate_external_hook_import_command', + { + request: { + workspacePath: normalizeOptionalWorkspacePath(workspacePath), + mutation: { schemaVersion: 1, expectedRevision, action }, + }, + }, + ); + return normalizeImportSnapshot(response); + }, }; diff --git a/src/web-ui/src/infrastructure/config/components/HooksConfig.test.tsx b/src/web-ui/src/infrastructure/config/components/HooksConfig.test.tsx new file mode 100644 index 0000000000..ac195dd76a --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/HooksConfig.test.tsx @@ -0,0 +1,364 @@ +// @vitest-environment jsdom + +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import HooksConfig from './HooksConfig'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const getConfigMock = vi.hoisted(() => vi.fn()); +const setConfigMock = vi.hoisted(() => vi.fn()); +const getSnapshotMock = vi.hoisted(() => vi.fn()); +const planImportMock = vi.hoisted(() => vi.fn()); +const applyImportMock = vi.hoisted(() => vi.fn()); +const mutateImportMock = vi.hoisted(() => vi.fn()); +const notifyErrorMock = vi.hoisted(() => vi.fn()); +const notifySuccessMock = vi.hoisted(() => vi.fn()); +const translateMock = vi.hoisted(() => vi.fn( + (key: string, params?: Record) => ( + params ? `${key}:${Object.values(params).join(':')}` : key + ), +)); + +vi.mock('react-i18next', () => ({ + initReactI18next: { type: '3rdParty', init: vi.fn() }, + useTranslation: () => ({ t: translateMock }), +})); + +vi.mock('@/component-library', () => ({ + Button: ({ children, disabled, onClick }: React.ButtonHTMLAttributes) => ( + + ), + ConfigPageLoading: ({ text }: { text: string }) =>
{text}
, + Switch: ({ checked, disabled, onChange }: React.InputHTMLAttributes) => ( + + ), + Modal: ({ children, isOpen, title }: { + children: React.ReactNode; + isOpen: boolean; + title?: string; + }) => (isOpen ?
{children}
: null), + ConfirmDialog: ({ confirmText, isOpen, message, onConfirm, title }: { + confirmText?: string; + isOpen: boolean; + message: React.ReactNode; + onConfirm: () => void; + title: string; + }) => (isOpen ? ( +
+ {message} + +
+ ) : null), +})); + +vi.mock('./common', () => ({ + ConfigPageContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + ConfigPageHeader: ({ title, subtitle }: { title: string; subtitle: string }) => ( +

{title}

{subtitle}

+ ), + ConfigPageLayout: ({ children }: { children: React.ReactNode }) =>
{children}
, + ConfigPageRow: ({ children, description, label }: { + children: React.ReactNode; + description?: React.ReactNode; + label: React.ReactNode; + }) =>
{label}{description}{children}
, + ConfigPageSection: ({ children, description, extra, title }: { + children: React.ReactNode; + description?: React.ReactNode; + extra?: React.ReactNode; + title: string; + }) =>

{title}

{description}{extra}{children}
, +})); + +vi.mock('@/infrastructure/contexts/WorkspaceContext', () => ({ + useCurrentWorkspace: () => ({ + workspace: { workspaceKind: 'normal' }, + workspacePath: 'D:/workspace/project', + }), +})); + +vi.mock('@/shared/notification-system', () => ({ + useNotification: () => ({ error: notifyErrorMock, success: notifySuccessMock }), +})); + +vi.mock('../services/ConfigManager', () => ({ + configManager: { getConfig: getConfigMock, setConfig: setConfigMock }, +})); + +vi.mock('@/infrastructure/api/service-api/ExternalHooksAPI', () => ({ + externalHooksAPI: { + getImportSnapshot: getSnapshotMock, + planImport: planImportMock, + applyImport: applyImportMock, + mutateImport: mutateImportMock, + }, +})); + +vi.mock('@/infrastructure/api/service-api/SystemAPI', () => ({ + systemAPI: { openExternal: vi.fn() }, +})); + +const source = { + 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: [], +}; + +const catalog = { + schemaVersion: 1, + discoveryPending: false, + providers: [], + sources: [source], + entries: [], + staleProviderIds: [], + failedProviderIds: [], + diagnostics: [], +}; + +const snapshot = { + schemaVersion: 1, + revision: 'sha256:revision-1', + catalog, + imports: [], + diagnostics: [], +}; + +const plan = { + schemaVersion: 1, + source, + disposition: 'import', + behaviorVersion: 'sha256:behavior', + handlers: [{ + stableKey: 'pre-tool-use-0', + event: 'PreToolUse', + matcher: 'Bash', + command: 'python D:/managed/hooks/check.py', + timeoutSeconds: 30, + dependencies: [{ kind: 'managed', relativePath: 'hooks/check.py' }], + }], + skipped: [{ reasonCode: 'unsupported_event', count: 1 }], + planFingerprint: 'sha256:plan-1', +}; + +async function flush() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +describe('HooksConfig imported Hook management', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + vi.clearAllMocks(); + getConfigMock.mockResolvedValue({ enabled: true, project_hooks_enabled: true }); + setConfigMock.mockResolvedValue(undefined); + getSnapshotMock.mockResolvedValue(snapshot); + planImportMock.mockResolvedValue(plan); + applyImportMock.mockResolvedValue({ + schemaVersion: 1, + outcome: { kind: 'applied', snapshot: { ...snapshot, revision: 'sha256:revision-2' } }, + }); + mutateImportMock.mockResolvedValue(snapshot); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + }); + + it('loads settings and sources together, then exposes exact commands before apply', async () => { + await act(async () => root.render()); + await flush(); + + expect(getConfigMock).toHaveBeenCalledTimes(1); + expect(getSnapshotMock).toHaveBeenCalledWith('D:/workspace/project', false); + const review = Array.from(container.querySelectorAll('button')) + .find((button) => button.textContent === 'imports.review')!; + await act(async () => review.click()); + await flush(); + + const dialog = container.querySelector('[role="dialog"]')!; + expect(dialog.textContent).toContain('python D:/managed/hooks/check.py'); + expect(dialog.textContent).toContain('unsupported_event'); + expect(dialog.textContent).not.toContain('sha256:plan-1'); + + const confirm = Array.from(dialog.querySelectorAll('button')) + .find((button) => button.textContent === 'imports.confirm')!; + await act(async () => confirm.click()); + await flush(); + expect(applyImportMock).toHaveBeenCalledWith('D:/workspace/project', plan); + }); + + it('keeps a stale replacement plan open and never applies it implicitly', async () => { + const refreshedPlan = { + ...plan, + handlers: [{ ...plan.handlers[0], command: 'python D:/managed/hooks/check-v2.py' }], + planFingerprint: 'sha256:plan-2', + }; + applyImportMock.mockResolvedValue({ + schemaVersion: 1, + outcome: { kind: 'stale', refreshedPlan }, + }); + await act(async () => root.render()); + await flush(); + await act(async () => { + Array.from(container.querySelectorAll('button')) + .find((button) => button.textContent === 'imports.review')!.click(); + }); + await flush(); + await act(async () => { + Array.from(container.querySelectorAll('button')) + .find((button) => button.textContent === 'imports.confirm')!.click(); + }); + await flush(); + + expect(container.textContent).toContain('python D:/managed/hooks/check-v2.py'); + expect(container.textContent).toContain('imports.stale'); + expect(applyImportMock).toHaveBeenCalledTimes(1); + }); + + it('removes only after a source-preservation confirmation', async () => { + getSnapshotMock.mockResolvedValue({ + ...snapshot, + imports: [{ + importId: 'managed-1', + source, + enabled: true, + behaviorVersion: 'sha256:behavior', + state: 'current', + }], + }); + await act(async () => root.render()); + await flush(); + await act(async () => { + Array.from(container.querySelectorAll('button')) + .find((button) => button.textContent === 'imports.remove')!.click(); + }); + expect(container.textContent).toContain('imports.removeSourceUntouched'); + await act(async () => { + Array.from(container.querySelectorAll('button')) + .find((button) => button.textContent === 'imports.removeConfirm')!.click(); + }); + await flush(); + expect(mutateImportMock).toHaveBeenCalledWith( + 'D:/workspace/project', + 'sha256:revision-1', + { kind: 'remove', importId: 'managed-1' }, + ); + }); + + it('refreshes stale mutation state without replaying the action', async () => { + const imported = { + ...snapshot, + imports: [{ + importId: 'managed-1', + source, + enabled: false, + behaviorVersion: 'sha256:behavior', + state: 'current', + }], + }; + const refreshed = { ...imported, revision: 'sha256:revision-2' }; + getSnapshotMock.mockReset(); + getSnapshotMock.mockResolvedValueOnce(imported).mockResolvedValueOnce(refreshed); + mutateImportMock.mockRejectedValue({ code: 'stale_revision' }); + await act(async () => root.render()); + await flush(); + + const importedSwitch = Array.from(container.querySelectorAll('input')).at(-1)!; + await act(async () => importedSwitch.click()); + await flush(); + + expect(mutateImportMock).toHaveBeenCalledTimes(1); + expect(getSnapshotMock).toHaveBeenCalledTimes(2); + expect(notifyErrorMock).toHaveBeenCalledWith('imports.stateChanged'); + }); + + it('keeps a disabled import disabled after review and reports the authoritative state', async () => { + const disabledImport = { + importId: 'managed-1', + source, + enabled: false, + behaviorVersion: 'sha256:behavior', + state: 'update_available', + }; + const imported = { ...snapshot, imports: [disabledImport] }; + getSnapshotMock.mockResolvedValue(imported); + planImportMock.mockResolvedValue({ ...plan, disposition: 'update' }); + applyImportMock.mockResolvedValue({ + schemaVersion: 1, + outcome: { + kind: 'applied', + snapshot: { ...imported, revision: 'sha256:revision-2' }, + }, + }); + + await act(async () => root.render()); + await flush(); + await act(async () => { + Array.from(container.querySelectorAll('button')) + .find((button) => button.textContent === 'imports.update')!.click(); + }); + await flush(); + + const dialog = container.querySelector('[role="dialog"]')!; + expect(dialog.textContent).toContain('imports.confirmUpdate'); + await act(async () => { + Array.from(dialog.querySelectorAll('button')) + .find((button) => button.textContent === 'imports.confirmUpdate')!.click(); + }); + await flush(); + + expect(notifySuccessMock).toHaveBeenCalledWith('imports.appliedDisabled'); + }); + + it('does not claim an imported source will run while the master switch is off', async () => { + getConfigMock.mockResolvedValue({ enabled: false, project_hooks_enabled: false }); + applyImportMock.mockResolvedValue({ + schemaVersion: 1, + outcome: { + kind: 'applied', + snapshot: { + ...snapshot, + revision: 'sha256:revision-2', + imports: [{ + importId: 'managed-1', + source, + enabled: true, + behaviorVersion: 'sha256:behavior', + state: 'current', + }], + }, + }, + }); + + await act(async () => root.render()); + await flush(); + await act(async () => { + Array.from(container.querySelectorAll('button')) + .find((button) => button.textContent === 'imports.review')!.click(); + }); + await flush(); + await act(async () => { + Array.from(container.querySelectorAll('button')) + .find((button) => button.textContent === 'imports.confirm')!.click(); + }); + await flush(); + + expect(notifySuccessMock).toHaveBeenCalledWith('imports.appliedMasterDisabled'); + }); +}); diff --git a/src/web-ui/src/infrastructure/config/components/HooksConfig.tsx b/src/web-ui/src/infrastructure/config/components/HooksConfig.tsx index 7893aa2167..33e55d8223 100644 --- a/src/web-ui/src/infrastructure/config/components/HooksConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/HooksConfig.tsx @@ -1,9 +1,18 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ExternalLink } from 'lucide-react'; -import { Button, ConfigPageLoading, Switch } from '@/component-library'; +import { ExternalLink, RefreshCw } from 'lucide-react'; +import { Button, ConfigPageLoading, ConfirmDialog, Modal, Switch } from '@/component-library'; +import { useCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext'; +import { WorkspaceKind } from '@/shared/types'; import { useNotification } from '@/shared/notification-system'; import { createLogger } from '@/shared/utils/logger'; +import { + externalHooksAPI, + type ExternalHookImportMutation, + type ExternalHookImportPlan, + type ExternalHookImportSnapshot, + type ExternalHookSource, +} from '@/infrastructure/api/service-api/ExternalHooksAPI'; import { systemAPI } from '@/infrastructure/api/service-api/SystemAPI'; import { configManager } from '../services/ConfigManager'; import { @@ -17,6 +26,7 @@ import { const log = createLogger('HooksConfig'); const CODEX_HOOKS_DOC_URL = 'https://learn.chatgpt.com/docs/hooks'; +const IMPORTABLE_HOOK_ECOSYSTEMS = new Set(['claude-code', 'codex']); /** Enablement gates only. Hook declarations live in hooks.json. */ interface AgentHooksConfigShape { @@ -41,28 +51,91 @@ function normalizeHooksConfig( const HooksConfig: React.FC = () => { const { t } = useTranslation('settings/hooks'); const { error: notifyError, success: notifySuccess } = useNotification(); + const { workspace, workspacePath } = useCurrentWorkspace(); + const remoteWorkspace = workspace?.workspaceKind === WorkspaceKind.Remote + || Boolean(workspace?.connectionId); const [loading, setLoading] = useState(true); const [config, setConfig] = useState(DEFAULT_HOOKS_CONFIG); const [savingKey, setSavingKey] = useState(null); + const [importSnapshot, setImportSnapshot] = useState(null); + const [importLoading, setImportLoading] = useState(false); + const [importError, setImportError] = useState(null); + const [busyKey, setBusyKey] = useState(null); + const [reviewPlan, setReviewPlan] = useState(null); + const [planNotice, setPlanNotice] = useState(null); + const [confirmation, setConfirmation] = useState< + | { kind: 'remove'; importId: string } + | { kind: 'reset'; scope: ExternalHookSource['scope'] } + | null + >(null); + const requestSequence = useRef(0); + const mountedRef = useRef(true); const loadData = useCallback(async () => { + const sequence = ++requestSequence.current; setLoading(true); - try { - const loaded = await configManager.getConfig>('app.hooks'); - setConfig(normalizeHooksConfig(loaded)); - } catch (error) { - log.error('Failed to load hooks config', error); - notifyError(error instanceof Error ? error.message : t('messages.loadFailed')); - } finally { - setLoading(false); + setImportError(null); + const [configResult, importResult] = await Promise.allSettled([ + configManager.getConfig>('app.hooks'), + remoteWorkspace + ? Promise.resolve(null) + : externalHooksAPI.getImportSnapshot(workspacePath || undefined, false), + ]); + if (!mountedRef.current || sequence !== requestSequence.current) return; + if (configResult.status === 'fulfilled') { + setConfig(normalizeHooksConfig(configResult.value)); + } else { + log.error('Failed to load hooks config', configResult.reason); + notifyError( + configResult.reason instanceof Error + ? configResult.reason.message + : t('messages.loadFailed'), + ); + } + if (importResult.status === 'fulfilled') { + setImportSnapshot(importResult.value); + } else { + log.error('Failed to load imported Hooks', importResult.reason); + setImportSnapshot(null); + setImportError(t('imports.loadFailed')); } - }, [notifyError, t]); + setLoading(false); + }, [notifyError, remoteWorkspace, t, workspacePath]); useEffect(() => { + mountedRef.current = true; void loadData(); + return () => { + mountedRef.current = false; + requestSequence.current += 1; + }; }, [loadData]); + const refreshImports = useCallback(async () => { + if (remoteWorkspace) return; + const sequence = ++requestSequence.current; + setImportLoading(true); + setImportError(null); + try { + const snapshot = await externalHooksAPI.getImportSnapshot( + workspacePath || undefined, + true, + ); + if (mountedRef.current && sequence === requestSequence.current) { + setImportSnapshot(snapshot); + } + } catch (error) { + if (!mountedRef.current || sequence !== requestSequence.current) return; + log.error('Failed to refresh imported Hooks', error); + setImportError(t('imports.refreshFailed')); + } finally { + if (mountedRef.current && sequence === requestSequence.current) { + setImportLoading(false); + } + } + }, [remoteWorkspace, t, workspacePath]); + const updateConfig = useCallback( async (key: K, value: AgentHooksConfigShape[K]) => { const previous = config; @@ -71,13 +144,15 @@ const HooksConfig: React.FC = () => { setConfig(next); try { await configManager.setConfig('app.hooks', next); + if (!mountedRef.current) return; notifySuccess(t('messages.saved')); } catch (error) { + if (!mountedRef.current) return; log.error('Failed to save hooks config', { key, error }); setConfig(previous); notifyError(error instanceof Error ? error.message : t('messages.saveFailed')); } finally { - setSavingKey(null); + if (mountedRef.current) setSavingKey(null); } }, [config, notifyError, notifySuccess, t] @@ -89,6 +164,134 @@ const HooksConfig: React.FC = () => { }); }, []); + const previewImport = useCallback(async (source: ExternalHookSource) => { + const key = `${source.key.providerId}:${source.key.sourceId}`; + setBusyKey(key); + setPlanNotice(null); + try { + const plan = await externalHooksAPI.planImport(workspacePath || undefined, source.key); + if (!mountedRef.current) return; + setReviewPlan(plan); + } catch (error) { + if (!mountedRef.current) return; + log.error('Failed to prepare Hook import review', error); + notifyError(t('imports.planFailed')); + } finally { + if (mountedRef.current) setBusyKey(null); + } + }, [notifyError, t, workspacePath]); + + const applyReviewedPlan = useCallback(async () => { + if (!reviewPlan) return; + setBusyKey('apply'); + try { + const result = await externalHooksAPI.applyImport( + workspacePath || undefined, + reviewPlan, + ); + if (!mountedRef.current) return; + if (result.outcome.kind === 'stale') { + setReviewPlan(result.outcome.refreshedPlan); + setPlanNotice(t('imports.stale')); + } else { + setImportSnapshot(result.outcome.snapshot); + setReviewPlan(null); + setPlanNotice(null); + const imported = result.outcome.snapshot.imports.find((item) => ( + item.source.key.providerId === reviewPlan.source.key.providerId + && item.source.key.sourceId === reviewPlan.source.key.sourceId + )); + let message = 'imports.applied'; + if (imported?.enabled === false) { + message = 'imports.appliedDisabled'; + } else if (!config.enabled) { + message = 'imports.appliedMasterDisabled'; + } + notifySuccess(t(message)); + } + } catch (error) { + if (!mountedRef.current) return; + log.error('Failed to apply reviewed Hook import', error); + notifyError(t('imports.applyFailed')); + } finally { + if (mountedRef.current) setBusyKey(null); + } + }, [config.enabled, notifyError, notifySuccess, reviewPlan, t, workspacePath]); + + const mutateImport = useCallback(async ( + action: ExternalHookImportMutation, + optimisticImportId?: string, + optimisticEnabled?: boolean, + ) => { + if (!importSnapshot) return; + let authoritative = importSnapshot; + if (optimisticImportId && optimisticEnabled !== undefined) { + setImportSnapshot({ + ...importSnapshot, + imports: importSnapshot.imports.map((item) => ( + item.importId === optimisticImportId + ? { ...item, enabled: optimisticEnabled } + : item + )), + }); + } + setBusyKey('mutation'); + try { + let next: ExternalHookImportSnapshot; + try { + next = await externalHooksAPI.mutateImport( + workspacePath || undefined, + authoritative.revision, + action, + ); + } catch (error) { + if ((error as { code?: string })?.code !== 'stale_revision') throw error; + const refreshed = await externalHooksAPI.getImportSnapshot( + workspacePath || undefined, + false, + ); + if (!mountedRef.current) return; + authoritative = refreshed; + setImportSnapshot(refreshed); + notifyError(t('imports.stateChanged')); + return; + } + if (!mountedRef.current) return; + setImportSnapshot(next); + notifySuccess(t('imports.updated')); + } catch (error) { + if (!mountedRef.current) return; + setImportSnapshot(authoritative); + log.error('Failed to update imported Hooks', error); + notifyError(t('imports.updateFailed')); + } finally { + if (mountedRef.current) setBusyKey(null); + } + }, [importSnapshot, notifyError, notifySuccess, t, workspacePath]); + + const confirmMutation = useCallback(() => { + if (!confirmation) return; + const action: ExternalHookImportMutation = confirmation.kind === 'remove' + ? { kind: 'remove', importId: confirmation.importId } + : { kind: 'reset_corrupt_store', scope: confirmation.scope }; + setConfirmation(null); + void mutateImport(action); + }, [confirmation, mutateImport]); + + const availableSources = importSnapshot?.catalog.sources + .filter((source) => IMPORTABLE_HOOK_ECOSYSTEMS.has(source.ecosystemId)) + .filter((source) => !importSnapshot.imports.some((item) => ( + item.source.key.providerId === source.key.providerId + && item.source.key.sourceId === source.key.sourceId + ))) ?? []; + const corruptDiagnostics = importSnapshot?.diagnostics.filter((diagnostic) => ( + diagnostic.code.startsWith('external_hook.import_store_corrupt.') + )) ?? []; + const reviewUpdatesExistingImport = reviewPlan !== null && importSnapshot?.imports.some((item) => ( + item.source.key.providerId === reviewPlan.source.key.providerId + && item.source.key.sourceId === reviewPlan.source.key.sourceId + )); + if (loading) { return ( @@ -149,6 +352,128 @@ const HooksConfig: React.FC = () => { + void refreshImports()} + > + + {t('imports.refresh')} + + )} + > + {remoteWorkspace ? ( + + {null} + + ) : importError ? ( + + {null} + + ) : importSnapshot ? ( + <> + {importSnapshot.imports.map((item) => ( + + void mutateImport( + { kind: 'set_enabled', importId: item.importId, enabled: event.target.checked }, + item.importId, + event.target.checked, + )} + /> + + + + ))} + + {availableSources.map((source) => { + const key = `${source.key.providerId}:${source.key.sourceId}`; + return ( + + + + ); + })} + + {corruptDiagnostics.map((diagnostic) => { + const scope = diagnostic.code.endsWith('.user_global') + ? 'user_global' + : 'project'; + return ( + + + + ); + })} + + {importSnapshot.imports.length === 0 + && availableSources.length === 0 + && corruptDiagnostics.length === 0 ? ( + + {null} + + ) : null} + + ) : ( + + )} + + { + + { + if (busyKey !== 'apply') { + setReviewPlan(null); + setPlanNotice(null); + } + }} + title={t('imports.reviewTitle', { name: reviewPlan?.source.displayName ?? '' })} + size="large" + closeOnOverlayClick={busyKey !== 'apply'} + > + {reviewPlan ? ( +
+ {planNotice ?

{planNotice}

: null} +

{t('imports.reviewWarning')}

+ {reviewPlan.handlers.map((handler) => ( +
+

{handler.event}{handler.matcher ? ` · ${handler.matcher}` : ''}

+
{handler.command}
+ {handler.commandWindows ?
{handler.commandWindows}
: null} +

{t('imports.timeout', { seconds: handler.timeoutSeconds ?? 60 })}

+ {handler.dependencies.map((dependency) => ( +

+ {t(`imports.dependency.${dependency.kind}`)}: { + dependency.kind === 'managed' + ? dependency.relativePath + : dependency.location + } +

+ ))} +
+ ))} + {reviewPlan.skipped.map((skipped) => ( +

+ {t('imports.skipped', { reason: skipped.reasonCode, count: skipped.count })} +

+ ))} + + +
+ ) : null} +
+ + setConfirmation(null)} + onConfirm={confirmMutation} + title={confirmation?.kind === 'reset' + ? t('imports.resetTitle') + : t('imports.removeTitle')} + message={confirmation?.kind === 'reset' + ? t('imports.resetWarning') + : t('imports.removeSourceUntouched')} + confirmText={confirmation?.kind === 'reset' + ? t('imports.resetConfirm') + : t('imports.removeConfirm')} + confirmDanger + />
); }; diff --git a/src/web-ui/src/locales/en-US/settings/hooks.json b/src/web-ui/src/locales/en-US/settings/hooks.json index 7e30849ae8..77e6e52917 100644 --- a/src/web-ui/src/locales/en-US/settings/hooks.json +++ b/src/web-ui/src/locales/en-US/settings/hooks.json @@ -12,7 +12,7 @@ "description": "Master switch. When off, no hook runs for any event." }, "projectHooks": { - "label": "Allow project hooks", + "label": "Enable project hook file", "description": "Honor the hook file inside the workspace. It executes commands from the checked-out repository, so enable this only for repositories you trust." } }, @@ -20,14 +20,64 @@ "title": "Where hooks are declared", "description": "Hooks live in hooks.json files, not in this settings document. Both layers are additive, with user hooks running first. Changes are picked up without restarting BitFun.", "userFile": { - "label": "User hooks", + "label": "User hook file", "description": "config/hooks.json in your BitFun user config directory (~/.config/bitfun on Linux, ~/Library/Application Support/bitfun on macOS, %APPDATA%\\bitfun on Windows)." }, "projectFile": { - "label": "Project hooks", - "description": ".bitfun/config/hooks.json inside the workspace. Only read when \"Allow project hooks\" is on." + "label": "Project hook file", + "description": ".bitfun/config/hooks.json inside the workspace. Only read when \"Enable project hook file\" is on." } }, + "imports": { + "title": "Imported hooks", + "description": "Review compatible Claude Code and Codex command hooks once, then manage BitFun's local snapshot here.", + "masterDisabled": "Imported hooks are preserved, but the master switch prevents them from running.", + "refresh": "Refresh", + "remoteUnsupported": "Imported hooks are unavailable for remote workspaces.", + "loading": "Loading imported hooks...", + "loadFailed": "Imported hooks could not be loaded.", + "refreshFailed": "Imported hooks could not be refreshed.", + "empty": "No compatible Claude Code or Codex command hooks were found.", + "review": "Review", + "update": "Review update", + "remove": "Remove", + "toggle": "Enable imported hooks from {{name}}", + "state": { + "current": "Current snapshot from {{location}}", + "update_available": "An update is available from {{location}}", + "source_missing": "The source is missing; the reviewed snapshot remains available", + "update_check_failed": "The update check failed; the reviewed snapshot remains available", + "bundle_missing": "Managed files are missing; review the source again to repair them" + }, + "planFailed": "The hook import could not be prepared for review.", + "reviewTitle": "Review hooks from {{name}}", + "reviewWarning": "These commands run with your account privileges. Confirm only after reviewing every command and dependency below.", + "timeout": "Effective timeout: {{seconds}} seconds", + "dependency": { + "managed": "Copied dependency", + "external": "External dependency" + }, + "skipped": "Skipped {{count}} item(s): {{reason}}", + "cancel": "Cancel", + "confirm": "Import reviewed hooks", + "confirmUpdate": "Apply reviewed update", + "stale": "The source changed. Nothing was written; review this refreshed plan.", + "applyFailed": "The reviewed hook import could not be applied.", + "applied": "Imported hooks are enabled for the next matching event.", + "appliedDisabled": "The reviewed update was applied; these imported hooks remain disabled.", + "appliedMasterDisabled": "Imported hooks are ready but will not run until Enable hooks is turned on.", + "updated": "Imported hook state updated.", + "stateChanged": "Imported hook state changed. Review the refreshed state and try again.", + "updateFailed": "Imported hook state could not be updated.", + "storeInvalid": "Imported hook state needs repair", + "reset": "Reset managed state", + "removeTitle": "Remove imported hooks?", + "removeSourceUntouched": "Only BitFun's managed copy is removed. The Claude Code or Codex source is not changed.", + "removeConfirm": "Remove managed copy", + "resetTitle": "Reset invalid imported hook state?", + "resetWarning": "This removes the invalid BitFun-managed index for this scope. Source application files are not changed.", + "resetConfirm": "Reset managed state" + }, "compatibility": { "title": "Codex compatibility", "description": "The hooks.json document, the event names, the JSON payload on stdin, the exit-code meanings, and the JSON decision schema on stdout match Codex hooks, so a Codex hook script runs here unchanged.", diff --git a/src/web-ui/src/locales/zh-CN/settings/hooks.json b/src/web-ui/src/locales/zh-CN/settings/hooks.json index 3b7ba593c4..73243293f8 100644 --- a/src/web-ui/src/locales/zh-CN/settings/hooks.json +++ b/src/web-ui/src/locales/zh-CN/settings/hooks.json @@ -12,7 +12,7 @@ "description": "总开关。关闭后,任何事件都不会运行 Hook。" }, "projectHooks": { - "label": "允许项目级 Hooks", + "label": "启用项目 Hook 文件", "description": "读取工作区内的 Hook 文件。它执行的是仓库中的命令,请只对你信任的仓库开启。" } }, @@ -20,14 +20,64 @@ "title": "Hook 声明位置", "description": "Hook 声明在 hooks.json 文件中,不在本设置文档里。两个层级是叠加关系,用户层优先执行。修改后无需重启 BitFun。", "userFile": { - "label": "用户 Hooks", + "label": "用户 Hook 文件", "description": "BitFun 用户配置目录下的 config/hooks.json(Linux 为 ~/.config/bitfun,macOS 为 ~/Library/Application Support/bitfun,Windows 为 %APPDATA%\\bitfun)。" }, "projectFile": { - "label": "项目 Hooks", - "description": "工作区内的 .bitfun/config/hooks.json。仅在开启“允许项目级 Hooks”时读取。" + "label": "项目 Hook 文件", + "description": "工作区内的 .bitfun/config/hooks.json。仅在开启“启用项目 Hook 文件”时读取。" } }, + "imports": { + "title": "已导入的 Hooks", + "description": "先审阅兼容的 Claude Code 和 Codex 命令 Hook,再在此管理 BitFun 的本地快照。", + "masterDisabled": "导入内容会被保留,但总开关关闭时不会运行。", + "refresh": "刷新", + "remoteUnsupported": "远程工作区暂不支持导入 Hooks。", + "loading": "正在加载已导入的 Hooks...", + "loadFailed": "无法加载已导入的 Hooks。", + "refreshFailed": "无法刷新已导入的 Hooks。", + "empty": "未发现兼容的 Claude Code 或 Codex 命令 Hooks。", + "review": "审阅", + "update": "审阅更新", + "remove": "移除", + "toggle": "启用来自 {{name}} 的 Hooks", + "state": { + "current": "来自 {{location}} 的当前快照", + "update_available": "{{location}} 有可用更新", + "source_missing": "来源已丢失;已审阅的快照仍会保留", + "update_check_failed": "更新检查失败;已审阅的快照仍会保留", + "bundle_missing": "托管文件缺失;请重新审阅来源以修复" + }, + "planFailed": "无法生成 Hook 导入审阅计划。", + "reviewTitle": "审阅来自 {{name}} 的 Hooks", + "reviewWarning": "这些命令将以你的账户权限运行。请检查下面的每条命令和依赖后再确认。", + "timeout": "实际超时:{{seconds}} 秒", + "dependency": { + "managed": "复制的依赖", + "external": "外部依赖" + }, + "skipped": "已跳过 {{count}} 项:{{reason}}", + "cancel": "取消", + "confirm": "导入已审阅的 Hooks", + "confirmUpdate": "应用已审阅的更新", + "stale": "来源已变化。本次没有写入,请重新审阅更新后的计划。", + "applyFailed": "无法应用已审阅的 Hook 导入。", + "applied": "导入的 Hooks 将从下一个匹配事件开始启用。", + "appliedDisabled": "已应用审阅后的更新;这些导入 Hooks 仍保持停用。", + "appliedMasterDisabled": "Hooks 已导入,但只有开启“启用 Hooks”后才会运行。", + "updated": "已更新导入 Hook 的状态。", + "stateChanged": "导入 Hook 的状态已变化。请检查刷新后的状态,再重新操作。", + "updateFailed": "无法更新导入 Hook 的状态。", + "storeInvalid": "导入 Hook 状态需要修复", + "reset": "重置托管状态", + "removeTitle": "移除已导入的 Hooks?", + "removeSourceUntouched": "只会移除 BitFun 的托管副本,不会修改 Claude Code 或 Codex 的来源文件。", + "removeConfirm": "移除托管副本", + "resetTitle": "重置无效的导入 Hook 状态?", + "resetWarning": "这会移除当前范围内无效的 BitFun 托管索引,不会修改来源应用的文件。", + "resetConfirm": "重置托管状态" + }, "compatibility": { "title": "Codex 兼容", "description": "hooks.json 文档结构、事件名、stdin 上的 JSON 载荷、退出码语义以及 stdout 上的 JSON 决策结构均与 Codex Hooks 一致,因此 Codex 的 Hook 脚本可以直接在这里运行。", diff --git a/src/web-ui/src/locales/zh-TW/settings/hooks.json b/src/web-ui/src/locales/zh-TW/settings/hooks.json index 6839ffd661..2dc7c9b693 100644 --- a/src/web-ui/src/locales/zh-TW/settings/hooks.json +++ b/src/web-ui/src/locales/zh-TW/settings/hooks.json @@ -12,7 +12,7 @@ "description": "總開關。關閉後,任何事件都不會執行 Hook。" }, "projectHooks": { - "label": "允許專案層級 Hooks", + "label": "啟用專案 Hook 檔案", "description": "讀取工作區內的 Hook 檔案。它執行的是儲存庫中的命令,請只對你信任的儲存庫開啟。" } }, @@ -20,14 +20,64 @@ "title": "Hook 宣告位置", "description": "Hook 宣告在 hooks.json 檔案中,不在本設定文件裡。兩個層級是疊加關係,使用者層優先執行。修改後無需重新啟動 BitFun。", "userFile": { - "label": "使用者 Hooks", + "label": "使用者 Hook 檔案", "description": "BitFun 使用者設定目錄下的 config/hooks.json(Linux 為 ~/.config/bitfun,macOS 為 ~/Library/Application Support/bitfun,Windows 為 %APPDATA%\\bitfun)。" }, "projectFile": { - "label": "專案 Hooks", - "description": "工作區內的 .bitfun/config/hooks.json。僅在開啟「允許專案層級 Hooks」時讀取。" + "label": "專案 Hook 檔案", + "description": "工作區內的 .bitfun/config/hooks.json。僅在開啟「啟用專案 Hook 檔案」時讀取。" } }, + "imports": { + "title": "已匯入的 Hooks", + "description": "先檢閱相容的 Claude Code 和 Codex 命令 Hook,再於此管理 BitFun 的本機快照。", + "masterDisabled": "匯入內容會保留,但總開關關閉時不會執行。", + "refresh": "重新整理", + "remoteUnsupported": "遠端工作區暫不支援匯入 Hooks。", + "loading": "正在載入已匯入的 Hooks...", + "loadFailed": "無法載入已匯入的 Hooks。", + "refreshFailed": "無法重新整理已匯入的 Hooks。", + "empty": "未發現相容的 Claude Code 或 Codex 命令 Hooks。", + "review": "檢閱", + "update": "檢閱更新", + "remove": "移除", + "toggle": "啟用來自 {{name}} 的 Hooks", + "state": { + "current": "來自 {{location}} 的目前快照", + "update_available": "{{location}} 有可用更新", + "source_missing": "來源已遺失;已檢閱的快照仍會保留", + "update_check_failed": "更新檢查失敗;已檢閱的快照仍會保留", + "bundle_missing": "受管理檔案遺失;請重新檢閱來源以修復" + }, + "planFailed": "無法產生 Hook 匯入檢閱計畫。", + "reviewTitle": "檢閱來自 {{name}} 的 Hooks", + "reviewWarning": "這些命令會以你的帳戶權限執行。請檢查下方每一條命令與相依項目後再確認。", + "timeout": "實際逾時:{{seconds}} 秒", + "dependency": { + "managed": "複製的相依項目", + "external": "外部相依項目" + }, + "skipped": "已略過 {{count}} 項:{{reason}}", + "cancel": "取消", + "confirm": "匯入已檢閱的 Hooks", + "confirmUpdate": "套用已檢閱的更新", + "stale": "來源已變更。本次未寫入,請重新檢閱更新後的計畫。", + "applyFailed": "無法套用已檢閱的 Hook 匯入。", + "applied": "匯入的 Hooks 將從下一個符合的事件開始啟用。", + "appliedDisabled": "已套用檢閱後的更新;這些匯入 Hooks 仍保持停用。", + "appliedMasterDisabled": "Hooks 已匯入,但只有開啟「啟用 Hooks」後才會執行。", + "updated": "已更新匯入 Hook 的狀態。", + "stateChanged": "匯入 Hook 的狀態已變更。請檢查重新整理後的狀態,再重新操作。", + "updateFailed": "無法更新匯入 Hook 的狀態。", + "storeInvalid": "匯入 Hook 狀態需要修復", + "reset": "重設受管理狀態", + "removeTitle": "移除已匯入的 Hooks?", + "removeSourceUntouched": "只會移除 BitFun 的受管理副本,不會修改 Claude Code 或 Codex 的來源檔案。", + "removeConfirm": "移除受管理副本", + "resetTitle": "重設無效的匯入 Hook 狀態?", + "resetWarning": "這會移除目前範圍內無效的 BitFun 受管理索引,不會修改來源應用程式的檔案。", + "resetConfirm": "重設受管理狀態" + }, "compatibility": { "title": "Codex 相容", "description": "hooks.json 文件結構、事件名稱、stdin 上的 JSON 載荷、結束碼語義以及 stdout 上的 JSON 決策結構均與 Codex Hooks 一致,因此 Codex 的 Hook 指令碼可以直接在這裡執行。",