From 66bd7a6360e01c3119ad5a16b652b37f73b86808 Mon Sep 17 00:00:00 2001 From: limityan Date: Tue, 28 Jul 2026 20:54:41 +0800 Subject: [PATCH 1/3] feat(hooks): import reviewed Claude and Codex commands --- docs/architecture/cli-product-line-design.md | 32 +- .../external-ai-work-sources-design.md | 59 +- docs/architecture/product-architecture.md | 8 +- docs/features/agent-hooks.md | 73 +- docs/features/agent-hooks.zh-CN.md | 61 +- ...-07-28-claude-codex-command-hook-import.md | 359 ++++ ...claude-codex-command-hook-import-design.md | 705 ++++++++ .../core-boundaries/rules/feature-rules.mjs | 15 +- .../rules/source/public-api-rules.mjs | 16 + src/apps/cli/src/actions.rs | 6 +- src/apps/cli/src/hook_import.rs | 340 ++++ src/apps/cli/src/main.rs | 72 + src/apps/cli/src/modes/chat.rs | 23 +- src/apps/cli/src/modes/chat/commands.rs | 17 +- src/apps/cli/src/modes/chat/external_hooks.rs | 602 ++++++- src/apps/cli/src/modes/chat/native_hooks.rs | 58 +- src/apps/cli/src/modes/chat/run.rs | 2 +- src/apps/cli/src/modes/chat/tests.rs | 66 +- .../desktop/src/api/external_hooks_api.rs | 125 +- .../src/api/remote_workspace_policy.rs | 32 + src/apps/desktop/src/lib.rs | 4 + .../claude-code-adapter/src/hook_source.rs | 309 +++- .../claude-code-adapter/tests/hook_source.rs | 203 +++ .../adapters/codex-adapter/src/hook_source.rs | 285 ++- .../codex-adapter/tests/hook_source.rs | 84 + .../adapters/static-hook-support/src/lib.rs | 380 +++- .../static-hook-support/tests/parser.rs | 97 +- .../assembly/core/src/external_hook_import.rs | 742 ++++++++ .../assembly/core/src/external_hooks.rs | 56 +- .../infrastructure/app_paths/path_manager.rs | 6 +- src/crates/assembly/core/src/lib.rs | 2 + src/crates/assembly/core/src/native_hooks.rs | 137 +- .../assembly/core/src/native_hooks_tests.rs | 60 +- .../assembly/external-sources/src/hook.rs | 44 +- .../tests/hook_coordinator.rs | 57 + .../src/external_hook_catalog.rs | 14 + .../src/external_hook_import.rs | 591 +++++++ .../contracts/product-domains/src/lib.rs | 3 + .../tests/external_hook_catalog_contracts.rs | 168 ++ .../services/services-integrations/Cargo.toml | 11 + .../services-integrations/src/hook_import.rs | 1526 +++++++++++++++++ .../services/services-integrations/src/lib.rs | 3 + .../api/service-api/ExternalHooksAPI.test.ts | 77 + .../api/service-api/ExternalHooksAPI.ts | 259 +++ .../config/components/HooksConfig.test.tsx | 289 ++++ .../config/components/HooksConfig.tsx | 415 ++++- .../src/locales/en-US/settings/hooks.json | 48 + .../src/locales/zh-CN/settings/hooks.json | 48 + .../src/locales/zh-TW/settings/hooks.json | 48 + 49 files changed, 8362 insertions(+), 275 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-28-claude-codex-command-hook-import.md create mode 100644 docs/superpowers/specs/2026-07-28-claude-codex-command-hook-import-design.md create mode 100644 src/apps/cli/src/hook_import.rs create mode 100644 src/crates/assembly/core/src/external_hook_import.rs create mode 100644 src/crates/contracts/product-domains/src/external_hook_import.rs create mode 100644 src/crates/services/services-integrations/src/hook_import.rs create mode 100644 src/web-ui/src/infrastructure/config/components/HooksConfig.test.tsx 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..9f87ba8ff0 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/docs/superpowers/plans/2026-07-28-claude-codex-command-hook-import.md b/docs/superpowers/plans/2026-07-28-claude-codex-command-hook-import.md new file mode 100644 index 0000000000..522f89b9a5 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-claude-codex-command-hook-import.md @@ -0,0 +1,359 @@ +# Claude Code / Codex Command Hook Import Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: use `executing-plans` for inline execution or the repository-approved subagent workflow for task-by-task execution. Keep the implementation in one final commit and run a context-isolated adversarial review before any push or PR update. + +**Goal:** Let local CLI/TUI and Desktop users explicitly review, import, update, enable, disable, and remove the synchronous Claude Code/Codex command-Hook subset through BitFun's existing native Hook runtime. + +**Architecture:** Keep `AgentHookEngine` as the only executor and `ExternalHookCatalogCoordinator` as the only external Hook discovery owner. Add one command-bearing product-domain contract, one managed persistence service, and one core plan/apply orchestrator. Extend existing adapters and product surfaces; do not add a Hook SDK, generic import framework, second coordinator, second executor, watcher, or OpenCode runtime. + +**Tech stack:** Rust, Tokio, Serde, SHA-256, Clap/TUI, Tauri, React/TypeScript, i18next. + +**Approved design:** `docs/superpowers/specs/2026-07-28-claude-codex-command-hook-import-design.md` + +## Hard scope and interface limits + +These are implementation acceptance gates, not suggestions: + +1. Do not change `bitfun-agent-runtime` Hook parsing, dispatch, scheduling, payload, or outcome interfaces. Imported data must enter as ordinary `AgentHookSettingsLayer` values. +2. Do not add commands, bodies, environment values, asset bytes, or trust state to `ExternalHookCatalogSnapshotV1`; its current redacted `content_version` remains unchanged. +3. Do not introduce a generic importer, Hook manager hierarchy, HookBus, executor registry, plugin protocol, external watcher, or remote execution path. +4. Do not add a second JSON/TOML Hook traversal. Refactor `static-hook-support` once, keeping `parse_hook_document` as a compatibility wrapper. +5. Do not duplicate backend behavior in CLI or React. Both surfaces call the same core plan/apply/snapshot/mutation operations. +6. Do not share MCP-specific DTOs with Hook import. Reuse only its established preview/apply/stale interaction and the existing atomic JSON primitive. +7. The serialized public surface is limited to the types listed in Task 1. Any additional serialized DTO, fourth backend owner module, or change to OpenCode execution requires a design review before proceeding. +8. Do not add a new frontend state framework, stylesheet, or Hook-specific dialog component. Extend `HooksConfig.tsx` and reuse `Modal`, `ConfirmDialog`, `ConfigPageSection`, `Button`, and `Switch`. + +## File map + +### New production files + +| File | Single responsibility | +|---|---| +| `src/crates/contracts/product-domains/src/external_hook_import.rs` | Versioned local-only import DTOs plus non-serialized prepared adapter facts. | +| `src/crates/services/services-integrations/src/hook_import.rs` | Bounded managed bundle/index IO and in-memory store snapshot. | +| `src/crates/assembly/core/src/external_hook_import.rs` | Product path selection, plan/apply fencing, mutations, update checks, and native-layer projection. | +| `src/apps/cli/src/hook_import.rs` | Root CLI text/JSON projection over the shared core operations. | + +### Existing files to extend + +| Area | Files | +|---|---| +| Shared parsing | `src/crates/adapters/static-hook-support/src/lib.rs`, `src/crates/adapters/static-hook-support/tests/parser.rs` | +| Provider port/coordinator | `src/crates/contracts/product-domains/src/external_hook_catalog.rs`, `src/crates/assembly/external-sources/src/hook.rs`, `src/crates/assembly/external-sources/tests/hook_coordinator.rs` | +| Ecosystem conversion | `src/crates/adapters/codex-adapter/src/hook_source.rs`, `src/crates/adapters/codex-adapter/tests/hook_source.rs`, `src/crates/adapters/claude-code-adapter/src/hook_source.rs`, `src/crates/adapters/claude-code-adapter/tests/hook_source.rs` | +| Native runtime wiring | `src/crates/assembly/core/src/native_hooks.rs`, `src/crates/assembly/core/src/native_hooks_tests.rs`, `src/crates/assembly/core/src/lib.rs`, plus the existing `PathManager` native path digest visibility needed to isolate colliding workspace slugs | +| Cargo and boundary facts | `src/crates/services/services-integrations/Cargo.toml`, `scripts/core-boundaries/rules/feature-rules.mjs`, `scripts/core-boundaries/rules/source/public-api-rules.mjs` | +| CLI/TUI | `src/apps/cli/src/main.rs`, `src/apps/cli/src/actions.rs`, `src/apps/cli/src/modes/chat.rs`, `src/apps/cli/src/modes/chat/native_hooks.rs`, `src/apps/cli/src/modes/chat/external_hooks.rs`, `src/apps/cli/src/modes/chat/commands.rs`, `src/apps/cli/src/modes/chat/run.rs`, `src/apps/cli/src/modes/chat/tests.rs` | +| Desktop transport | `src/apps/desktop/src/api/external_hooks_api.rs`, `src/apps/desktop/src/api/remote_workspace_policy.rs`, `src/apps/desktop/src/lib.rs` | +| Web API/UI | `src/web-ui/src/infrastructure/api/service-api/ExternalHooksAPI.ts`, `src/web-ui/src/infrastructure/api/service-api/ExternalHooksAPI.test.ts`, `src/web-ui/src/infrastructure/config/components/HooksConfig.tsx`, new `HooksConfig.test.tsx` beside it | +| i18n/docs | `src/web-ui/src/locales/{en-US,zh-CN,zh-TW}/settings/hooks.json`, `docs/features/agent-hooks.md`, `docs/features/agent-hooks.zh-CN.md`, only the architecture sentences that still call Codex/Claude Hooks reference-only | + +No production change is planned under `src/crates/execution/agent-runtime` or the OpenCode adapter. + +## Stable operation surface + +Task 1 must implement only this serialized shape, with `camelCase`, `deny_unknown_fields`, bounded validation, schema version `1`, and redacted custom `Debug` wherever exact commands can appear: + +```rust +pub struct ExternalHookImportHandlerV1 { + 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, +} + +pub struct ExternalHookImportSkippedV1 { + pub reason_code: String, + pub count: u32, +} + +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, +} + +pub struct ExternalHookImportApplyRequestV1 { + pub schema_version: u32, + pub source: SourceKey, + pub plan_fingerprint: String, +} + +pub enum ExternalHookImportApplyOutcomeV1 { + Applied { snapshot: ExternalHookImportSnapshotV1 }, + Unchanged { snapshot: ExternalHookImportSnapshotV1 }, + Stale { refreshed_plan: ExternalHookImportPlanV1 }, +} + +pub struct ExternalHookImportApplyResultV1 { + pub schema_version: u32, + pub outcome: ExternalHookImportApplyOutcomeV1, +} + +pub struct ImportedHookSourceSnapshotV1 { + pub import_id: String, + pub source: ExternalHookSource, + pub enabled: bool, + pub behavior_version: String, + pub state: ImportedHookSourceStateV1, +} + +pub struct ExternalHookImportSnapshotV1 { + pub schema_version: u32, + pub revision: String, + pub catalog: ExternalHookCatalogSnapshotV1, + pub imports: Vec, + pub diagnostics: Vec, +} + +pub struct ExternalHookImportMutationRequestV1 { + pub schema_version: u32, + pub expected_revision: String, + pub action: ExternalHookImportMutationV1, +} + +pub enum ExternalHookImportMutationV1 { + SetEnabled { import_id: String, enabled: bool }, + Remove { import_id: String }, + ResetCorruptStore { scope: ExternalSourceScope }, +} +``` + +`ExternalHookImportDispositionV1`, `ExternalHookImportDependencyV1`, and `ImportedHookSourceStateV1` are closed enums used by these DTOs; do not add parallel summary/action types. The snapshot embeds the existing redacted catalog so CLI and Desktop do not invent different “available plus imported” merge contracts. The public `revision` is a stable hash of the user-store generation, workspace-store generation/state, and workspace identity, because one `u64` cannot fence two scope stores. Mutations return `ExternalHookImportSnapshotV1`; stale revisions use the existing `ExternalSourceOperationErrorCode::StaleRevision` rather than another result envelope. + +The non-serialized provider result is one `PreparedExternalHookImport` containing normalized handler facts, aggregated skip facts, and bounded asset bytes. It has a custom redacted `Debug`, computes one private `behavior_version`, and is not a persisted or user-authored format. + +## Task 1: Lock contracts and make one shared document walk + +**Files:** + +- Add `src/crates/contracts/product-domains/src/external_hook_import.rs`. +- Modify `src/crates/contracts/product-domains/src/external_hook_catalog.rs`. +- Modify `src/crates/contracts/product-domains/src/lib.rs`. +- Modify `src/crates/contracts/product-domains/tests/external_hook_catalog_contracts.rs`. +- Modify `src/crates/adapters/static-hook-support/src/lib.rs`. +- Modify `src/crates/adapters/static-hook-support/tests/parser.rs`. + +- [x] Add failing contract tests for exact JSON field names, schema rejection, unknown-field rejection, bounded command/diagnostic counts, invalid identifiers, stable SourceKey round trips, and redacted `Debug` output. +- [x] Add failing parser tests proving one traversal preserves current redacted results for JSON and TOML while exposing borrowed group/handler values only to a closure. +- [x] Introduce `visit_hook_document(bytes, format, max_handlers, visitor)` and a borrowed `StaticHookHandlerRef` in `static-hook-support`. The returned summary contains only `all_disabled`, structural issues, and `inspected_handlers`; it never retains raw `serde_json::Value` data. +- [x] Reimplement the existing `parse_hook_document` as a wrapper around `visit_hook_document`. Keep its signature, ordering, issue de-duplication, handler limits, and `redacted_parse_content_version` byte-for-byte compatible in tests. +- [x] Add `PreparedExternalHookImport` and the versioned DTOs above. Centralize prepared-fact validation and behavior hashing here so Codex and Claude adapters do not implement separate fingerprint logic. +- [x] Extend `ExternalHookSourceProvider` with one default `prepare_import(context, source, expected_catalog_content_version)` method returning the standard unsupported provider error. Do not add another provider trait. +- [x] Register the module under the existing `external-sources` feature; do not add a new product-domain feature. + +Run: + +```powershell +cargo test -p bitfun-product-domains --features external-sources external_hook +cargo test -p bitfun-static-hook-support +``` + +Expected: new tests fail before implementation, then pass; existing catalog snapshots and redacted content versions remain unchanged. + +## Task 2: Convert the conservative Codex and Claude command subset + +**Files:** + +- Modify both existing adapter `hook_source.rs` files and their existing tests. +- Modify `static-hook-support` only for shared static path recognition/asset collection needed by both adapters. + +- [x] Add Codex fixtures for user/project `hooks.json`, inline TOML, `commandWindows`, timeout/status, unsupported handler types, async/unknown behavior fields, unsupported events, malformed matchers, and command-only changes that keep catalog `content_version` stable but change private `behavior_version`. +- [x] Add Claude fixtures for `settings.json` and `settings.local.json`, missing `type`, `timeoutSec`, `disableAllHooks`, group/handler `if`, `async`, `asyncRewake`, `args`, `shell`, `once`, non-command types, unknown fields, and Claude-only events. +- [x] Implement both conversions through `visit_hook_document`; do not parse the same file again in an adapter-specific walker. +- [x] Preserve deterministic source/group/handler ordering and aggregate skipped reasons by stable reason code. +- [x] Add one shared conservative path helper for statically recognizable references under the source `hooks/` directory. Preserve unrecognized commands verbatim, reject dynamic source-root expressions, and identify absolute external dependencies for review. +- [x] Collect only referenced regular files. Reject links/reparse points, traversal, unreadable files, and budgets over 256 files, 1 MiB per file, 16 MiB total, or depth 8. Read asset bytes during preparation so the behavior digest and later write use the same bounded content. +- [x] Ensure no `CLAUDE_*`/`CODEX_*` environment, credentials, enablement, or trust data is copied. + +Run: + +```powershell +cargo test -p bitfun-codex-adapter hook_source +cargo test -p bitfun-claude-code-adapter hook_source +cargo test -p bitfun-static-hook-support +``` + +Expected: eligible handlers normalize to the current native field names; unsupported semantics remain visible only as skip counts/reasons; OpenCode code and tests do not change. + +## Task 3: Persist one bounded managed snapshot store + +**Files:** + +- Add `src/crates/services/services-integrations/src/hook_import.rs` with inline unit tests. +- Modify `src/crates/services/services-integrations/src/lib.rs` and `Cargo.toml`. +- Modify `src/crates/assembly/core/Cargo.toml` to enable the narrow service feature through `product-full`. + +- [x] Add focused tests for empty/valid restart-safe load, corrupt index fail-closed/reset without generation ABA, generation fencing, idempotent apply, invalid scope and management IDs, missing-bundle repair, same-path repair failure preserving the indexed bundle, pre-index-commit publication rollback, successful retired-bundle cleanup, refusal to re-enable a missing bundle, valid-but-modified Hook/asset rejection, exact-version removal, and managed-ancestor link/reparse rejection. Fixed source-asset budgets stay covered by the shared preparation tests; atomic index replacement stays covered by `JsonFileStore`. +- [x] Add a `hook-import` service feature depending only on `bitfun-services-core`, `bitfun-product-domains/external-sources`, `bitfun-agent-runtime` for native document validation, `sha2`, `hex`, `thiserror`, and `uuid`. Add it to the existing `product-full` feature; do not reuse the broader `plugin-source` feature. +- [x] Implement `HookImportStore` with one root path supplied by assembly. Its private index contains only schema, a generation token that increments while valid and is freshly reseeded after corrupt reset, import identity, source key/kind/scope plus redacted display/location facts needed after restart, behavior version, active behavior-digest directory, exact managed-content digest, and enabled state. Do not persist the generation-sensitive review fingerprint, catalog diagnostics, exact review DTOs, source commands outside the managed native file, or foreign trust state. +- [x] Reuse `JsonFileStore::acquire_cross_process_lock` and `write_atomic_strict` for the index. Do not add another atomic JSON helper. Bound index reads before deserialization without expanding `JsonFileStore` unless a second real consumer is found. +- [x] Stage `hooks.json` and asset bytes in a sibling temporary directory before touching the indexed path, validate the generated layer with `AgentHookSettings::from_layers`, compute the exact managed-content digest, move to `bundles//`, atomically publish the index, then update the in-memory snapshot. A same-path repair retires the old directory only after staging and restores it when final rename or index publication fails. Verify exact indexed content on store open/index change. Cleanup old versions only after publication; cleanup failure leaves inactive residue and never rolls back or reactivates it. +- [x] Make disable an index-only change. Make remove publish the index first and then delete only the removed record's exact digest directory while retaining the same cross-process lock. `ResetCorruptStore { scope }` is accepted only while that scoped store is corrupt and requires the caller's current combined revision. A corrupt store contributes an index-metadata/error marker to the revision; reset publishes a freshly seeded non-zero generation so old generation-zero plans cannot recur. +- [x] Track the index metadata fingerprint so another local BitFun process causes one reload on the next access; do not enumerate bundle directories or scan external products. + +Run: + +```powershell +cargo test -p bitfun-services-integrations --no-default-features --features hook-import hook_import +``` + +Expected: every failed stage keeps the previous index and active bundle; no test writes outside its temporary managed root. + +## Task 4: Add plan/apply orchestration and feed the existing engine + +**Files:** + +- Add `src/crates/assembly/core/src/external_hook_import.rs` with inline unit tests. +- Modify `src/crates/assembly/core/src/external_hooks.rs`, `src/crates/assembly/core/src/native_hooks.rs`, `src/crates/assembly/core/src/native_hooks_tests.rs`, and `src/crates/assembly/core/src/lib.rs`. +- Modify `src/crates/assembly/external-sources/src/hook.rs` and its existing coordinator tests. + +- [x] Add focused tests for guarded preparation and redacted-version mismatch, provider-default unsupported behavior, private plan fingerprint fencing, native document compatibility, stable import IDs, missing-bundle recovery, deterministic native layer order, project imports independent of the manual project-file gate, next-generation cache replacement with an unchanged captured engine, and remote dispatch rejection. Keep source-missing/update-failure behavior fail-closed in the typed snapshot path without adding a second test-only coordinator. +- [x] Add a guarded synchronous preparation method to the existing `ExternalHookCatalogCoordinator`; it resolves the registered provider and current source and verifies the expected redacted catalog version. Wrap that method in a per-source in-flight `OnceCell` inside the existing core `WorkspaceExternalHookCatalogService`, using `spawn_blocking`, so simultaneous Desktop/TUI requests await the same preparation. Remove the cell after completion; do not cache completed prepared commands across requests because command-only changes do not change catalog `content_version`. +- [x] Expose these core functions and no second facade: + +```rust +pub async fn external_hook_import_snapshot( + workspace: Option<&Path>, + refresh_updates: bool, +) -> ExternalSourceOperationResult; + +pub async fn plan_external_hook_import( + workspace: Option<&Path>, + source: SourceKey, +) -> ExternalSourceOperationResult; + +pub async fn apply_external_hook_import( + workspace: Option<&Path>, + request: ExternalHookImportApplyRequestV1, +) -> ExternalSourceOperationResult; + +pub async fn mutate_external_hook_import( + workspace: Option<&Path>, + request: ExternalHookImportMutationRequestV1, +) -> ExternalSourceOperationResult; +``` + +- [x] Derive user/workspace store roots from the existing `PathManager`; pass roots down to the service. Reject remote workspaces before local file access. +- [x] Materialize the managed asset root before public review, serialize one native `hooks.json` from the same normalized handler facts, validate it with the current parser, and compute the plan fingerprint over source/catalog guard, private behavior version, fully materialized handler review, and current target generation. +- [x] On apply, refresh/guard the source, prepare again, rebuild the plan, compare the accepted fingerprint, and return `Stale { refreshed_plan }` without a write when anything changed. +- [x] Keep one bounded core cache of `Arc` by managed root so native dispatch, CLI, and Desktop share in-process generation/state. Do not create a generic import-store registry. +- [x] In `native_hooks::engine_for`, obtain the known imported layer snapshot asynchronously, then assemble this order: manual user, enabled imported user ordered by import ID, gated manual project, enabled imported workspace ordered by import ID. Add store generation/index fingerprint to `CachedHookEngine`; keep existing manual metadata fingerprints. +- [x] Keep an in-flight `Arc` unchanged. Mutations affect the next event only. Do not change command concurrency or outcome aggregation. +- [x] Extend `NativeHookOverview` construction to show imported files/rules through the same assembled paths; do not add a second imported-rule projection. + +Run: + +```powershell +cargo test -p bitfun-external-sources hook +cargo test -p bitfun-core external_hook_import +cargo test -p bitfun-core native_hook +node scripts/check-core-boundaries.mjs +``` + +Expected: manual Hooks still work when import state is absent/corrupt; a successful mutation invalidates exactly one cached generation; remote dispatch remains skipped. + +## Task 5: Make CLI/TUI the complete primary surface + +**Files:** + +- Add `src/apps/cli/src/hook_import.rs`. +- Modify the existing CLI/TUI files in the file map. + +- [x] Add focused Clap/TUI parser tests for preview-only import, confirmed destructive removal/reset, aliases, and the explicit second step for interactive writes; expose this complete command family: + +```text +bitfun hooks list [--refresh] [--format text|json] +bitfun hooks import --source [--confirm ] [--format text|json] +bitfun hooks update [--confirm ] [--format text|json] +bitfun hooks enable +bitfun hooks disable +bitfun hooks remove --confirm +bitfun hooks reset --confirm +``` + +- [x] Implement root commands as thin projections over the four core operations. `hooks list` renders `snapshot.catalog` plus `snapshot.imports`, including each catalog source's stable key. Import/update without a fingerprint is preview-only; a stale fingerprint prints/serializes the refreshed plan and exits without writing. Mutation staleness is returned to the user and never automatically replayed. Keep protocol stdout free of logs. +- [x] Replace the separate read-only TUI mental model with one `/hooks` renderer combining `NativeHookOverview`, `ExternalHookImportSnapshotV1`, and the existing redacted catalog. Keep `/hooks_external` and `/hooks-external` as compatibility aliases to the same management view. +- [x] Support `/hooks [refresh|import |update |enable |disable |remove |reset ]`. The first import/update invocation stores exactly one pending plan in `ChatMode` and renders full commands, Windows overrides, effective timeout, dependencies, skips, and fingerprint. Repeating the same action with `--confirm` applies that cached fingerprint; remove and corrupt-store-only reset also require `--confirm`. Do not add another confirmation popup or ask the user to type the long hash. +- [x] Replace the current Hook catalog receiver with one CLI-local Hook management result enum/receiver and one optional pending plan. Do not add parallel receivers for plan/apply/mutation. +- [x] Escape every source label, command, path, diagnostic, and error with the existing terminal escaping helper. Bound displayed lists while keeping JSON complete. +- [x] Update action help, command palette, asynchronous management refresh, and existing chat tests. Preserve old aliases and root command compatibility. + +Run: + +```powershell +cargo test -p bitfun-cli hooks +cargo check -p bitfun-cli +``` + +Expected: CLI is functionally complete without Desktop; every write requires explicit review confirmation; `/hooks_external` no longer exposes a separate implementation path. + +## Task 6: Add the Desktop transport and compact Settings UX + +**Files:** + +- Modify the Desktop and Web files in the file map; add only `HooksConfig.test.tsx`. + +- [x] Add Desktop tests for structured camelCase requests, nested core requests, unknown-field rejection, and explicit `Reject` remote policies for snapshot/plan/apply/mutate commands; retain the existing command-registration contract coverage. +- [x] Extend `external_hooks_api.rs` with four Tauri commands that map directly to the four core operations. Keep the existing catalog command and file; do not add another API module. +- [x] Extend `ExternalHooksAPI.ts` with exact bounded validators for the v1 DTOs. Reuse `invokeExternalSourceCommand`, `exactRecord`, `boundedString`, SourceKey parsing, and common error handling; do not create a second client service. +- [x] Add component tests for the highest-risk interactions: parallel initial load plus exact command/skipped-reason review before apply; stale plan replacement without implicit apply; stale mutation refresh without action replay; and destructive removal only after a source-preservation confirmation. Keep remaining state transitions thin projections over the closed, validator-tested API. +- [x] Extend `HooksConfig.tsx` with one imported-source section and one reused `Modal` for plan review. Reuse `ConfirmDialog` for remove/reset. Keep the two existing switches and make the master switch gate imported execution without deleting state. +- [x] Load discovery only when the Hooks settings page mounts or the user refreshes. Do not poll after unmount and do not add a file watcher. +- [x] Add only owner-namespace strings to `settings/hooks.json` for `en-US`, `zh-CN`, and `zh-TW`; preserve product names as interpolated/provider display facts rather than hard-coded translated brand variants. + +Run: + +```powershell +pnpm --dir src/web-ui run test:run src/infrastructure/api/service-api/ExternalHooksAPI.test.ts src/infrastructure/config/components/HooksConfig.test.tsx +pnpm run i18n:contract:test +pnpm run type-check:web +cargo check -p bitfun-desktop +``` + +Expected: the common path is “review once, then enabled”; only arbitrary-code import/update and destructive remove/reset require confirmation. + +## Task 7: Documentation, focused verification, and adversarial scope review + +**Files:** + +- Modify `docs/features/agent-hooks.md` and `docs/features/agent-hooks.zh-CN.md`. +- Modify only architecture sentences that still describe Claude/Codex Hooks as permanently reference-only. +- Update this plan's checkboxes during execution; do not add another implementation spec. + +- [x] Document import destination, user/workspace scope, enable/disable/remove, no startup re-import, no watcher, explicit update review, next-event hot reload, actual deterministic layer order, external dependency behavior, remote unsupported state, and OpenCode runtime deferral. +- [x] Run `pnpm run fmt:rs` and `git diff --check`. +- [x] Run all focused commands from Tasks 1-6, then `cargo check --workspace`, `pnpm run i18n:audit`, and `pnpm run check:repo-hygiene`. +- [x] Inspect `git diff --stat` and the public API diff. Stop and redesign if the implementation added a fourth backend owner, a second parser/executor/coordinator, a generic import abstraction, an unlisted serialized DTO, OpenCode runtime behavior, watcher code, or agent scheduling changes. +- [x] Search for duplicated conversions and surface logic: + +```powershell +rg -n "prepare_import|behavior_version|plan_fingerprint|visit_hook_document|HookImportStore" src/crates src/apps/cli/src src/web-ui/src +``` + +Expected: one provider port, one shared document walk, one behavior-version implementation, one store, one core plan/apply owner, and thin surfaces. + +- [x] Before any push, spawn a context-isolated subagent to adversarially review the entire diff against the approved design, `AGENTS.md`, architecture boundaries, command-execution safety, atomicity, performance, i18n, and the explicit non-goals. Fix every actionable finding and rerun affected checks. +- [x] Rebase onto latest `gcwing/main`, resolve conflicts without broad rewrites, rerun affected focused checks, and repeat the adversarial diff review if the rebase changes behavior. +- [x] Squash/amend the design, plan, implementation, fixes, and documentation into one accurate final commit. Push only to `limityan/BitFun`; never push directly to `GCWing/BitFun`. Do not wait for CI unless explicitly requested, but do not claim pending/unreported checks passed. + +## Completion evidence + +The PR is ready only when the handoff can show: + +- one CLI import/update transcript proving preview, stale rejection, apply, disable, and remove; +- one Desktop component test proving exact command review and shared backend state; +- one restart/load test proving no external rediscovery or re-import; +- one next-event generation test proving hot reload without changing an in-flight engine; +- one atomic failure test proving the old version remains active; +- one unsafe asset test for links/reparse points and budget rejection; +- no changes to OpenCode execution, Agent Hook scheduling, or the redacted catalog DTO; +- a clean, context-isolated adversarial review after the final diff. diff --git a/docs/superpowers/specs/2026-07-28-claude-codex-command-hook-import-design.md b/docs/superpowers/specs/2026-07-28-claude-codex-command-hook-import-design.md new file mode 100644 index 0000000000..afddd3c7da --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-claude-codex-command-hook-import-design.md @@ -0,0 +1,705 @@ +# Claude Code / Codex command Hook import design + +## Status and baseline + +Status: approved for implementation planning. + +This design is based on the following current sources as of 2026-07-28: + +- BitFun `gcwing/main` at `9f705a41c`; +- Codex `openai/codex` `main` at `e597169e9a`; +- OpenCode `anomalyco/opencode` `dev` at `017a5977d2`; +- the current [Codex Hooks reference](https://developers.openai.com/codex/hooks) and + [Claude Code Hooks reference](https://code.claude.com/docs/en/hooks). + +The implementation belongs in one focused PR. It makes the already-discovered, +compatible Claude Code and Codex command Hooks usable in BitFun without waiting +for the JavaScript Plugin Host. It does not implement OpenCode Hook execution or +create a general Hook SDK. + +## Problem + +BitFun currently has two separate, valid pieces: + +1. `bitfun-agent-runtime::native_hooks::AgentHookEngine` executes BitFun's + Codex-compatible `hooks.json` command handlers. Product wiring in + `assembly/core/src/native_hooks.rs` reads the user and project files and + dispatches the eleven supported lifecycle events. +2. The external Hook catalog discovers Claude Code, Codex, and OpenCode Hook + declarations through ecosystem adapters. It is intentionally read-only and + redacts handler bodies, commands, environment data, and credentials. + +Consequently, `/hooks_external` can tell a user that compatible command Hooks +exist, but using them still requires manually copying and rewriting source +configuration. Manual copying has four concrete problems: + +- BitFun cannot distinguish imported declarations from hand-written BitFun + Hooks, so disabling, updating, or removing one source is unsafe; +- source-relative scripts and product-specific variables may stop resolving; +- unsupported Claude Code fields can be silently misunderstood; +- there is no version-fenced review between discovering arbitrary commands and + enabling their execution. + +The product goal is therefore narrow: let a local user explicitly review, +import, enable, update, disable, and remove the synchronous command-Hook subset +that the existing BitFun engine can execute faithfully. + +## Decision summary + +The first version uses a **BitFun-managed import snapshot**: + +- external files remain read-only and are never mounted as live runtime config; +- an import writes a separate BitFun-owned native `hooks.json` layer plus only + the source-owned Hook assets that can be copied and rewritten safely; +- discovery and import planning are on demand and asynchronous; +- startup never re-imports a source and no persistent external file watcher is + installed; +- import/update is an atomic, version-fenced plan/apply operation; +- one source-level switch controls the imported snapshot; removal touches only + BitFun-owned files; +- a successful mutation invalidates the current Hook engine generation and is + effective for the next lifecycle event, without changing in-flight Hooks; +- imported command layers reuse the current native Hook dispatch and execution + path; +- OpenCode remains a static catalog until its JavaScript Hook execution is + delivered through `PluginRuntimeClient`. + +No public concept named "bundle" or "manifest" is introduced. Product surfaces +say "Imported from Claude Code" or "Imported from Codex". + +### Alternatives rejected + +| Approach | Why it is not v1 | +| --- | --- | +| Merge into the user's existing `hooks.json` | Loses provenance and makes source-level update, disable, and removal unsafe. | +| Read external files directly at runtime | Creates two live truth sources, source-product trust ambiguity, and startup/event-path IO. | +| Managed snapshot (selected) | Adds one small private store, but gives stable review, rollback, and lifecycle behavior without a new runtime. | + +## Goals + +- Preserve one Hook lifecycle owner and one command execution implementation. +- Make the imported source, scope, exact commands, and skipped items reviewable + before arbitrary code is enabled. +- Make disable and removal reversible and source-scoped. +- Avoid adding work to ordinary startup and Agent-event hot paths beyond loading + already-enabled BitFun-owned layers. +- Give CLI/TUI and Desktop the same typed plan/apply and lifecycle operations. +- Fail without changing the last active snapshot when discovery, conversion, or + persistence fails. + +## Non-goals + +This PR does not add: + +- live mounting or continuous watching of `.claude` or `.codex` files; +- automatic updates after an external source changes; +- OpenCode JavaScript Hook execution; +- Claude Code `http`, `mcp_tool`, `prompt`, or `agent` handlers; +- Claude Code async handlers, `asyncRewake`, `args`, custom `shell`, `if`, or + component-frontmatter Hooks; +- Codex plugin-bundled or administrator-managed Hooks; +- a generic Hook SDK, HookBus, executor registry, or new plugin protocol; +- remote-workspace Hook execution; +- per-handler editing, reordering, or policy overrides; +- migration of credentials, environment variables, or external product trust + records. + +## Current implementation constraints + +### Native command Hooks + +The current native owner already supplies the required execution primitive: + +- user file: `/config/hooks.json`; +- project file: `/.bitfun/config/hooks.json`; +- global master gate: `app.hooks.enabled`; +- manual project-file gate: `app.hooks.project_hooks_enabled`; +- local-only execution, with all Hooks skipped for remote workspaces; +- bounded files, handler count, timeouts, captured output, and model-visible + output; +- Tokio child processes with JSON stdin and bounded stdout/stderr; +- a per-workspace engine cache rebuilt when configured file fingerprints change. + +The cache currently checks manual file metadata at every dispatch and reads a +changed file synchronously while rebuilding. The import design must not add +external source scans or imported-directory enumeration to that event path. + +The current engine runs matching handlers sequentially and stops launching later +handlers after a final blocking outcome. Current Codex and Claude Code run all +matching command Hooks concurrently. That is an existing native compatibility +gap, not created by import. This PR must document the actual BitFun ordering but +must not combine import work with a scheduler and outcome-aggregation rewrite. + +### External Hook catalog + +`ExternalHookCatalogCoordinator` already supplies bounded, coalesced background +discovery with last-valid snapshots. Claude Code, Codex, and OpenCode adapters +own native file discovery and syntax interpretation. + +`ExternalHookCatalogSnapshotV1` must remain a safe summary. It must not be +expanded to carry commands simply because import needs them. Import preparation +is a separate, explicit operation with a narrower local consumer. + +Its current `content_version` is also deliberately computed from redacted +catalog facts, so changing only a command body does not change that value. It +must remain a catalog-generation guard and must not be reused as an import +update signal. Explicit import preparation computes a separate private +`behavior_version` over the complete normalized import behavior. + +### Architecture guardrails + +The repository architecture requires one Hook Coordinator. Source adapters may +interpret native declarations, while deadline, cancellation, permission +effects, event ordering, and outcome aggregation remain in the Agent owner. The +first import implementation therefore extends the inputs assembled by the +existing `native_hooks` wiring; it does not create a parallel runtime owner. + +## Product behavior + +### Unified mental model + +`/hooks` and **Settings -> Agent Hooks** become the single management surface: + +```text +Agent Hooks + Active + BitFun user Hooks + Imported from Claude Code Enabled + Available to import + Codex project Hooks 3 compatible, 1 skipped + Needs attention + Claude Code source changed Review update +``` + +The only normal workflow is: + +```text +Detect -> review exact commands and skipped reasons -> import and enable +``` + +Afterwards the user manages the source with `Update`, `Disable/Enable`, or +`Remove`. Import and enable is one confirmed action; there is no second +activation ceremony. + +`/hooks_external` remains as a compatibility alias that opens or renders the +external/available section of `/hooks`. It no longer represents a separate +product concept. + +### Scope behavior + +- User-global Claude Code or Codex configuration imports into a user-global + BitFun layer and applies to local workspaces. +- Project or local-project configuration imports into a BitFun-managed layer + associated with that local workspace. +- Import scope follows the source scope and is not user-remappable in v1. +- `app.hooks.enabled` remains the master switch for manual and imported Hooks. +- `app.hooks.project_hooks_enabled` continues to gate only the repository-owned + `.bitfun/config/hooks.json` file. A reviewed imported workspace snapshot has + its own source-level enabled state, so importing it does not silently enable + unrelated repository-owned BitFun Hooks. + +### Additive behavior and conflicts + +Hook declarations are additive; two different commands on the same event and +matcher are not a name conflict and both remain visible and executable. The +design does not add a main selector or a conflict-resolution UI. + +Exact repeated declarations are shown as duplicates in the review, but v1 does +not silently change their execution semantics across independently imported +sources. This avoids choosing an arbitrary provider as owner and matches the +current BitFun layer model. A user can disable the duplicate source as one unit. + +## Import compatibility + +### Codex + +Supported sources: + +- user and project `hooks.json`; +- user and project inline `[hooks]` tables in `config.toml`. + +Supported declarations are the eleven event names already implemented by +`AgentHookEvent`, with `type = "command"` handlers containing only fields the +BitFun engine currently honors: + +- `command`; +- `commandWindows` / `command_windows`; +- `timeout`; +- `statusMessage`. + +`prompt`, `agent`, async execution, `additionalContextLimit`, plugin-bundled +Hooks, managed Hooks, and unknown behavior-affecting fields are skipped with an +explicit reason. Codex trust and per-Hook enablement records are not copied; +confirmation of the exact BitFun import plan is a new BitFun-local decision. + +### Claude Code + +Supported sources: + +- `~/.claude/settings.json`; +- `/.claude/settings.json`; +- `/.claude/settings.local.json`. + +Conversion follows the conservative subset used by Codex's current Claude Hook +migration: + +- a missing handler `type` is normalized to `command`; +- `timeoutSec` is normalized to `timeout`; +- synchronous command handlers with `command`, optional timeout, and optional + `statusMessage` are eligible; +- group-level or handler-level `if`, unknown group fields, `async`, + `asyncRewake`, `args`, `shell`, `once`, and non-command handler types are + skipped; +- `disableAllHooks: true` is respected and produces no eligible import. + +Only events implemented by BitFun are considered. Newer Claude-only events such +as `Setup`, `PostToolUseFailure`, `Notification`, task/team events, worktree +events, file/config change events, and MCP elicitation events remain visible in +the static catalog but are not importable. + +### Commands and source-owned assets + +Import does not attempt to parse arbitrary shell syntax or emulate another +product's complete environment. + +- Commands without a known source-relative Hook path are preserved verbatim. +- Statically recognizable references under the source's `hooks/` directory may + be copied into the managed snapshot and rewritten to that copied location. +- Only regular files inside the source Hook directory are copied. Links, + reparse points, escapes outside the source root, unreadable files, and copies + exceeding fixed implementation budgets make the affected handler ineligible. +- Dynamic source-root expressions that cannot be rewritten safely are skipped. +- Absolute dependencies outside the source Hook directory are not copied; they + remain explicit external dependencies in the review. +- BitFun does not inject `CLAUDE_*` or `CODEX_*` compatibility variables and + never copies credentials or environment values. + +The copied asset budgets are internal safety constants, not user-facing +configuration. V1 uses at most 256 regular files, 1 MiB per file, 16 MiB total, +and eight directory levels. These match the existing managed-package byte +budgets where applicable and must be covered by tests. + +## Persistent representation + +Imported Hooks are product-owned runtime data, not user-authored configuration +and not repository content. + +User-global imports live below: + +```text +PathManager::user_data_dir()/hook-imports/ +``` + +Workspace imports live below: + +```text +PathManager::project_runtime_root(workspace)/hook-imports/ +``` + +Each root contains one private `index.json` and versioned bundle directories: + +```text +hook-imports/ + index.json + bundles/// + hooks.json + hooks/... +``` + +The private index stores only fields needed by a real consumer: + +- schema version and a generation token that increments while the index is + valid and is freshly reseeded after explicit corrupt-store reset; +- stable import id, provider id, source key, source kind/scope, and the + catalog's redacted display/location facts needed after restart; +- imported private behavior version and a digest of the exact managed + `hooks.json` plus asset bytes; +- active bundle version and enabled state. + +There is no public import file format and no compatibility promise for the +directory layout. Product surfaces consume typed snapshots and actions, never +read the index directly. + +### Atomic writes + +Import and update use the following order: + +1. prepare and validate the complete new bundle in a sibling temporary + directory without touching the indexed path; +2. parse its `hooks.json` with the existing `AgentHookSettings` parser; +3. move it to a new immutable version directory; when repairing the same + behavior path, first retire the old directory and restore it if the final + rename fails; +4. atomically replace `index.json` so it points at the new version and increments + generation; if index publication returns an error, remove the newly published + bundle and restore the retired indexed directory before releasing the store lock; +5. publish the new in-memory registry snapshot; +6. remove only the old unreferenced digest directory on a best-effort cleanup + path while the same per-store lock is still held. + +If steps 1-3 fail, the old index and active bundle are unchanged. A handled step-4 +failure rolls the bundle path back to match the old index. A process crash between +bundle and index publication can leave residue or a digest mismatch; bounded +verification on the next open fails that imported layer closed. If old-version +cleanup fails after step 4, the old directory is inactive residue; it is never +reactivated implicitly and can be removed by a later maintenance pass. + +Disable changes only the indexed enabled state. Remove first removes the index +record and publishes the new generation, then deletes only the digest directory +named by that removed record while the same per-store lock is held. It never +deletes a sibling version that another process may have published, and never +edits or deletes the Claude Code/Codex source. A removed source can be +re-imported later. + +## Typed operations and ownership + +### Contracts + +The Hook domain adds the minimum plan/apply DTOs needed by CLI and Desktop: + +- `ExternalHookImportPlanV1` with source identity/version, current disposition, + exact eligible command summaries, skipped items with reason codes, and a plan + fingerprint; +- `ExternalHookImportApplyRequestV1` with source key and accepted fingerprint; +- `ExternalHookImportApplyResultV1`, returning applied, unchanged, or stale with + a refreshed plan; +- `ImportedHookSourceSnapshotV1` and closed enable/disable/remove actions, plus + a corrupt-store-only reset action scoped to the affected user/workspace + store. + +The management snapshot embeds the existing redacted catalog and exposes one +combined revision over the user and workspace stores. Mutations fence that +revision instead of inventing separate surface actions for each store. A stale +mutation is never replayed automatically: Desktop refreshes its visible state, +while CLI/TUI reports staleness and requires an explicit refresh/retry. The user +must trigger enable, disable, remove, or reset again against the new revision. +Corrupt reset chooses a fresh non-zero generation token so a pre-reset plan or +mutation cannot become valid again through ABA. + +Command-bearing DTOs require redacted `Debug` implementations and must never be +written to logs or exposed through remote/peer APIs. Exact commands are shown +only because local users must review the code BitFun is about to execute. + +The existing catalog DTO remains unchanged and redacted. + +Interface stability is protected by three limits: + +- the public serialized surface contains one versioned plan, apply request and + result, imported-source snapshot, and one closed source mutation action; +- provider-to-assembly prepared data is non-serialized and has redacted + `Debug`; it does not become a user configuration format; +- there is no generic import manager, import registry, Hook executor trait, or + second management state model shared with MCP merely because both use a + review/apply interaction. + +### Source adapters + +`ExternalHookSourceProvider` gains a default `prepare_import` operation keyed by +source key and expected redacted catalog version. The default result is +unsupported, so OpenCode and future catalog-only providers do not acquire +execution behavior. The Claude Code and Codex adapters implement it by rereading +the selected source and producing a prepared native layer, a private +`behavior_version`, and asset copy facts. + +Both implementations extend the existing `static-hook-support` document walk. +The current redacted `parse_hook_document` remains a compatibility wrapper over +that single walk; import conversion receives borrowed group/handler objects +through a closure and does not introduce a second JSON/TOML traversal or copy +raw handler values into the catalog result. + +Adapters own only native syntax, source precedence, conservative conversion, +and rewrite diagnostics. They do not write BitFun state or enable execution. + +The OpenCode provider keeps the default unsupported preparation result. Its +JavaScript callbacks are not converted into commands. + +### Durable store and assembly + +- `services-integrations` owns a small `HookImportStore`: containment checks, + bounded asset persistence, index/bundle persistence, and cleanup. It reuses + `services-core::JsonFileStore::write_atomic_strict` for the index instead of + adding another atomic JSON writer. +- `assembly/external-sources` continues to own coalesced Hook discovery and the + current provider generation. +- a focused `assembly/core::external_hook_import` module follows the existing + external MCP import plan/apply pattern: build plan, re-prepare on apply, + compare fingerprints, call the store, and return typed outcomes. +- `assembly/core::native_hooks` remains the product Hook dispatch owner. It adds + enabled imported paths to the current native layer list and includes the + import generation in its engine cache key. +- `bitfun-agent-runtime` keeps the same parser, engine, payload, and outcome + types; it receives ordinary `AgentHookSettingsLayer` values and remains free + of filesystem/import/provider dependencies. + +This reuses plan/apply and generation-fencing logic structurally without +sharing MCP-specific data types or adding Hook to the generic external-source +control object. + +The immutable bundle directory is named from the private behavior digest, not +the accepted plan fingerprint. A separate private content digest covers the +exact generated `hooks.json`, sorted relative asset paths, and asset bytes. This +keeps the final managed asset path known before the review DTO is rendered, +avoids a fingerprint that contains its own path, and lets a restart reject a +syntactically valid but modified snapshot. The plan fingerprint covers the +fully materialized commands, behavior digest, source/catalog guard, and current +target generation; it is not persisted. + +## Runtime loading and performance + +### Startup + +Process startup never discovers Claude Code/Codex sources and never re-imports +them. On first access to an import store, and only after its index metadata +changes, BitFun verifies the content of the exact indexed bundle paths against +their bounded private digests. It does not enumerate unreferenced version +directories or external product files. Normal Hook events check only index +metadata and reuse the existing `AgentHookEngine` until the generation changes. + +If the import index is unreadable or invalid, imported Hooks fail closed and a +diagnostic is surfaced; manual BitFun Hooks continue to work. Recovery does not +delete data automatically. A destructive reset is offered only from the error +state and requires explicit confirmation. + +### Discovery and update checks + +External discovery runs only when: + +- the user opens `/hooks` or Agent Hooks settings; +- the user explicitly refreshes; +- an import/update operation needs a fresh plan. + +It reuses the current `ExternalHookCatalogCoordinator` discovery lane, so +simultaneous Desktop/TUI requests coalesce and blocking filesystem reads remain +off the UI thread. Concurrent preparation for the same source/version also +coalesces. Closing the view discards late UI results and does not leave a poller +or watcher behind. + +Catalog refresh alone produces only source presence and redacted structure. For +an already imported source, the management refresh asynchronously calls guarded +import preparation and compares its private `behavior_version` with the index. +That produces `current`, `update available`, `source missing`, or `update check +failed`. This work runs only in the management/explicit-refresh path and is +coalesced per source. A changed source never changes execution until the user +reviews and applies a new plan. + +### Runtime hot reload + +Import, update, enable, disable, and remove atomically publish a new import +generation. `native_hooks::engine_for` includes that generation and the known +manual-file fingerprints in its cache comparison. The next lifecycle event +rebuilds the engine once and reads the new immutable imported layer set. + +An event that already captured an `Arc` completes with that +engine. In-flight child processes are neither killed nor reconfigured by a +management action. + +External file edits are not runtime hot reload. They appear as `update +available` after an on-demand refresh. + +## Layer order and permission behavior + +Native layers are assembled in deterministic order: + +1. manual user `hooks.json`; +2. enabled user-global imported sources ordered by stable import id; +3. manual project `.bitfun/config/hooks.json`, when its existing gate is on; +4. enabled imported workspace sources ordered by stable import id. + +The order is visible in `/hooks`. The implementation does not add source +priority editing. + +Imported Hooks have exactly the current BitFun command-Hook authority: + +- they execute as the local BitFun user; +- a Hook can narrow permission but cannot widen a rule-based denial; +- modified tool input must continue through the owning validation and permission + path; +- post-tool feedback cannot undo a completed side effect; +- remote workspaces skip all Hook dispatch rather than running locally against a + remote path. + +No import action establishes plugin, MCP, tool, or OpenCode execution approval. + +## CLI and Desktop surfaces + +### Interactive CLI/TUI + +`/hooks` becomes asynchronous and renders the last current native/import state +immediately while external discovery refreshes in the background. It supports: + +```text +/hooks +/hooks refresh +/hooks import [--confirm] +/hooks update [--confirm] +/hooks enable +/hooks disable +/hooks remove --confirm +/hooks reset --confirm +``` + +Import/update first renders: + +- source and scope; +- every exact command that will execute, with effective timeout and Windows + override where applicable; +- copied or external asset dependencies; +- skipped event/handler count and concise reason for each class; +- the plan fingerprint. + +The existing interactive confirmation accepts that exact plan. Terminal text is +escaped through the existing CLI diagnostic helpers. + +### Root CLI + +For scripting and non-interactive use, add the matching root command family: + +```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 +``` + +Without `--confirm `, import is preview-only. Non-interactive +execution never approves the plan it just discovered. A stale confirmation +returns the refreshed plan and performs no write. + +Reset is exposed only for explicit recovery of a corrupt BitFun-managed scope. +It requires confirmation and leaves the Claude Code/Codex source intact. + +### Desktop + +The current **Agent Hooks** settings page keeps its two existing global/manual +project switches and adds one compact imported-source section. It uses the same +plan/apply APIs as CLI and does not parse source files in React. + +The review dialog shows exact commands and skipped reasons before import or +update. Enable/disable is immediate; remove confirms that only the BitFun copy +will be deleted and the source application is unchanged. + +All new copy uses the owning settings namespace and the repository i18n flow. + +## Failure behavior + +| Failure | Behavior | +| --- | --- | +| External discovery fails | Keep current imports active; show stale/failed discovery. | +| Source changes after review | Return `stale` with a refreshed plan; write nothing. | +| Import state changes during enable/disable/remove/reset | Refresh visible state, report stale, and require the user to trigger the action again. | +| Source is invalid or has no compatible handlers | Show skip reasons; do not create an empty import. | +| Asset cannot be copied safely | Skip the affected handler; never follow an escaping link. | +| Bundle validation or persistence fails | Keep the previous active version and generation. | +| Indexed `hooks.json` or asset content no longer matches its digest | Fail that imported bundle closed; update republishes the reviewed snapshot. | +| Enable finds a missing/corrupt bundle | Stay disabled and require update or removal. | +| Source disappears later | Continue using the snapshot; show `source missing`; allow disable/remove. | +| Old bundle cleanup fails | New state remains authoritative; inactive residue is never selected again. | +| A managed path ancestor is a link or Windows reparse point | Reject the operation without reading or deleting through it. | +| Import index is corrupt | Disable imported layers only; manual Hooks continue; offer explicit reset. | +| Corrupt index is explicitly reset | Publish an empty index with a fresh generation token; pre-reset plans remain stale. | +| Workspace is remote | Return a clear unsupported state; never execute locally as a fallback. | + +## OpenCode consistency boundary + +Claude Code and Codex command Hooks become native layers before runtime, so they +reuse the existing Hook lifecycle dispatch and command backend. OpenCode Hooks +are JavaScript callbacks that can mutate input/output objects and receive an +OpenCode plugin context; they require the plugin execution domain. + +The consistent architecture is therefore: + +```text +Claude/Codex source adapters --explicit import--> native command layers + | +BitFun native hooks.json ---------------------------+--> current native_hooks owner + | + AgentHookEngine +OpenCode adapter --future approved declarations----+--> future PluginRuntimeClient backend +``` + +Consistency means one lifecycle owner, one permission revalidation path, and +one aggregate outcome policy. It does not mean one source schema or one physical +executor. + +The PR does not expand `ExternalHookPoint` beyond the events consumed by a real +OpenCode runtime, does not add a generic executor trait in anticipation of that +runtime, and does not claim the existing OpenCode catalog is executable. + +## Verification + +Minimum automated coverage: + +- adapter fixtures for Codex JSON/TOML and Claude user/project/local sources; +- table-driven eligible/skipped cases for every supported and explicitly + unsupported field/type/event class; +- path rewrite, link/reparse escape, copy-budget, and containment tests on the + platforms where those rules differ; +- plan fingerprint and stale-apply tests; +- atomic import/update failure tests proving the previous bundle remains active; +- enable, disable, remove, missing source, missing bundle, and corrupt index + lifecycle tests; +- engine layer-order and generation-invalidation tests; +- remote-workspace skip tests; +- CLI text/JSON contract tests and interactive command tests; +- Desktop API serialization, remote policy declaration, component behavior, + accessibility, and i18n checks. + +The implementation PR should run the smallest matching checks, at minimum: + +```text +cargo test -p bitfun-agent-runtime native_hook +cargo test -p bitfun-external-sources hook +cargo test -p bitfun-codex-adapter hook +cargo test -p bitfun-claude-code-adapter hook +cargo test -p bitfun-cli hooks +cargo check --workspace +node scripts/check-core-boundaries.mjs +pnpm run i18n:contract:test +pnpm run type-check:web +``` + +Documentation updates in the implementation PR: + +- update `docs/features/agent-hooks.md` and its Chinese counterpart with import, + lifecycle, no-live-sync, and actual ordering behavior; +- update CLI help and Settings copy; +- update architecture documents only where they currently say Codex/Claude are + reference-only, without restating this entire feature design. + +## Acceptance criteria + +The slice is complete when all of the following are true: + +1. A local CLI user can discover a Claude Code or Codex source, review exact + compatible commands and skipped reasons, confirm one version-fenced plan, + and have the imported source run on the next matching BitFun Hook event. +2. The same imported source and actions are visible in Desktop Agent Hooks + settings through the same backend state and operations. +3. Disable takes effect on the next event; remove deletes only BitFun-owned data; + re-import does not duplicate the source record. +4. Restart loads enabled snapshots without rediscovering or re-importing the + external products. +5. Editing an external source does not change runtime behavior until a reviewed + update is applied. +6. A stale plan, failed write, unsafe asset, corrupt bundle, or remote workspace + cannot silently execute new code. +7. OpenCode Hook execution and all runtime-dependent handler kinds remain + unchanged and explicitly unsupported. + +## Scope stop conditions + +The implementation must stop and return to design review if it begins to +require any of the following: + +- a general-purpose Hook SDK or third-party executor registry; +- JavaScript module loading or Plugin Host lifecycle changes; +- persistent external file watchers; +- migration of secrets, permissions, or foreign trust databases; +- remote Hook execution; +- per-handler editing or source-priority policy; +- changes to Agent loop scheduling unrelated to loading imported layers. + +These are separate capabilities, not prerequisites for the command-Hook import +user outcome. 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..810c49da7e --- /dev/null +++ b/src/apps/cli/src/hook_import.rs @@ -0,0 +1,340 @@ +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, + plan_fingerprint, + }, + ) + .await + .map_err(operation_error)?; + let text = match &result.outcome { + bitfun_product_domains::external_hook_import::ExternalHookImportApplyOutcomeV1::Applied { + .. + } => "Imported Hooks are enabled and will apply on the next matching event.".to_string(), + bitfun_product_domains::external_hook_import::ExternalHookImportApplyOutcomeV1::Unchanged { + .. + } => "The reviewed Hook import is already current and enabled.".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) 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") +} + +pub(crate) fn render_plan(plan: &ExternalHookImportPlanV1) -> String { + 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.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") +} + +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) +} 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..4138c3f8c6 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,463 @@ 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, + plan_fingerprint: plan.plan_fingerprint, + }, ) - .await; - if !matches!(&result, Ok(snapshot) if snapshot.discovery_pending) { - break result; + .await?; + match result.outcome { + ExternalHookImportApplyOutcomeV1::Stale { refreshed_plan } => { + Ok(HookManagementResult::Plan(refreshed_plan)) + } + ExternalHookImportApplyOutcomeV1::Applied { snapshot } + | ExternalHookImportApplyOutcomeV1::Unchanged { snapshot } => { + let native = native_hook_overview(Some(workspace_root.as_path())).await; + Ok(HookManagementResult::Changed { + snapshot: HookManagementSnapshot { + native, + imports: snapshot, + }, + status: "Imported Hooks are enabled for the next matching event." + .to_string(), + }) + } } - force_refresh = false; - tokio::time::sleep(Duration::from_millis(250)).await; - }; - let _ = sender.send(result); + }, + "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(&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..c5fd8febef 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()), @@ -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..9459b1db56 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")); @@ -827,8 +861,6 @@ mod tests { 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..833db9149b --- /dev/null +++ b/src/crates/services/services-integrations/src/hook_import.rs @@ -0,0 +1,1526 @@ +//! 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_bounded(&hooks_path, MAX_HOOKS_FILE_BYTES as u64).await { + Ok(Some(bytes)) => bytes, + Ok(None) | 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> { + 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(()) +} + +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 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..c86b560aaa --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/HooksConfig.test.tsx @@ -0,0 +1,289 @@ +// @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'); + + 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'); + }); +}); diff --git a/src/web-ui/src/infrastructure/config/components/HooksConfig.tsx b/src/web-ui/src/infrastructure/config/components/HooksConfig.tsx index 7893aa2167..e553b941ff 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,120 @@ 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); + notifySuccess(t('imports.applied')); + } + } 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); + } + }, [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.') + )) ?? []; + if (loading) { return ( @@ -149,6 +338,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 })} +

+ ))} +

{t('imports.fingerprint', { fingerprint: reviewPlan.planFingerprint })}

+ + +
+ ) : 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..ce8639e27a 100644 --- a/src/web-ui/src/locales/en-US/settings/hooks.json +++ b/src/web-ui/src/locales/en-US/settings/hooks.json @@ -28,6 +28,54 @@ "description": ".bitfun/config/hooks.json inside the workspace. Only read when \"Allow project hooks\" 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}}", + "fingerprint": "Reviewed plan: {{fingerprint}}", + "cancel": "Cancel", + "confirm": "Import and enable", + "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.", + "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..5a0ceec5eb 100644 --- a/src/web-ui/src/locales/zh-CN/settings/hooks.json +++ b/src/web-ui/src/locales/zh-CN/settings/hooks.json @@ -28,6 +28,54 @@ "description": "工作区内的 .bitfun/config/hooks.json。仅在开启“允许项目级 Hooks”时读取。" } }, + "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}}", + "fingerprint": "已审阅计划:{{fingerprint}}", + "cancel": "取消", + "confirm": "导入并启用", + "stale": "来源已变化。本次没有写入,请重新审阅更新后的计划。", + "applyFailed": "无法应用已审阅的 Hook 导入。", + "applied": "导入的 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..eb1e6e4db1 100644 --- a/src/web-ui/src/locales/zh-TW/settings/hooks.json +++ b/src/web-ui/src/locales/zh-TW/settings/hooks.json @@ -28,6 +28,54 @@ "description": "工作區內的 .bitfun/config/hooks.json。僅在開啟「允許專案層級 Hooks」時讀取。" } }, + "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}}", + "fingerprint": "已檢閱計畫:{{fingerprint}}", + "cancel": "取消", + "confirm": "匯入並啟用", + "stale": "來源已變更。本次未寫入,請重新檢閱更新後的計畫。", + "applyFailed": "無法套用已檢閱的 Hook 匯入。", + "applied": "匯入的 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 指令碼可以直接在這裡執行。", From 60367c486ce4fb96ce506c316aba32c0396b980b Mon Sep 17 00:00:00 2001 From: limityan Date: Wed, 29 Jul 2026 03:53:44 +0800 Subject: [PATCH 2/3] fix(hooks): verify runtime bundles and disabled state Revalidate managed Hook content before rebuilding the native engine and reuse the verified bytes for parsing. Report preserved disabled imports accurately in both CLI surfaces. --- ...-07-28-claude-codex-command-hook-import.md | 4 +- ...claude-codex-command-hook-import-design.md | 14 ++- src/apps/cli/src/hook_import.rs | 96 ++++++++++++++++++- src/apps/cli/src/modes/chat/external_hooks.rs | 33 +++---- .../services-integrations/src/hook_import.rs | 47 ++++++++- 5 files changed, 161 insertions(+), 33 deletions(-) diff --git a/docs/superpowers/plans/2026-07-28-claude-codex-command-hook-import.md b/docs/superpowers/plans/2026-07-28-claude-codex-command-hook-import.md index 522f89b9a5..d9e29fe6e3 100644 --- a/docs/superpowers/plans/2026-07-28-claude-codex-command-hook-import.md +++ b/docs/superpowers/plans/2026-07-28-claude-codex-command-hook-import.md @@ -192,11 +192,11 @@ Expected: eligible handlers normalize to the current native field names; unsuppo - Modify `src/crates/services/services-integrations/src/lib.rs` and `Cargo.toml`. - Modify `src/crates/assembly/core/Cargo.toml` to enable the narrow service feature through `product-full`. -- [x] Add focused tests for empty/valid restart-safe load, corrupt index fail-closed/reset without generation ABA, generation fencing, idempotent apply, invalid scope and management IDs, missing-bundle repair, same-path repair failure preserving the indexed bundle, pre-index-commit publication rollback, successful retired-bundle cleanup, refusal to re-enable a missing bundle, valid-but-modified Hook/asset rejection, exact-version removal, and managed-ancestor link/reparse rejection. Fixed source-asset budgets stay covered by the shared preparation tests; atomic index replacement stays covered by `JsonFileStore`. +- [x] Add focused tests for empty/valid restart-safe load, corrupt index fail-closed/reset without generation ABA, generation fencing, idempotent apply, invalid scope and management IDs, missing-bundle repair, same-path repair failure preserving the indexed bundle, pre-index-commit publication rollback, successful retired-bundle cleanup, refusal to re-enable a missing bundle, valid-but-modified Hook/asset rejection including same-process runtime loading, exact-version removal, and managed-ancestor link/reparse rejection. Fixed source-asset budgets stay covered by the shared preparation tests; atomic index replacement stays covered by `JsonFileStore`. - [x] Add a `hook-import` service feature depending only on `bitfun-services-core`, `bitfun-product-domains/external-sources`, `bitfun-agent-runtime` for native document validation, `sha2`, `hex`, `thiserror`, and `uuid`. Add it to the existing `product-full` feature; do not reuse the broader `plugin-source` feature. - [x] Implement `HookImportStore` with one root path supplied by assembly. Its private index contains only schema, a generation token that increments while valid and is freshly reseeded after corrupt reset, import identity, source key/kind/scope plus redacted display/location facts needed after restart, behavior version, active behavior-digest directory, exact managed-content digest, and enabled state. Do not persist the generation-sensitive review fingerprint, catalog diagnostics, exact review DTOs, source commands outside the managed native file, or foreign trust state. - [x] Reuse `JsonFileStore::acquire_cross_process_lock` and `write_atomic_strict` for the index. Do not add another atomic JSON helper. Bound index reads before deserialization without expanding `JsonFileStore` unless a second real consumer is found. -- [x] Stage `hooks.json` and asset bytes in a sibling temporary directory before touching the indexed path, validate the generated layer with `AgentHookSettings::from_layers`, compute the exact managed-content digest, move to `bundles//`, atomically publish the index, then update the in-memory snapshot. A same-path repair retires the old directory only after staging and restores it when final rename or index publication fails. Verify exact indexed content on store open/index change. Cleanup old versions only after publication; cleanup failure leaves inactive residue and never rolls back or reactivates it. +- [x] Stage `hooks.json` and asset bytes in a sibling temporary directory before touching the indexed path, validate the generated layer with `AgentHookSettings::from_layers`, compute the exact managed-content digest, move to `bundles//`, atomically publish the index, then update the in-memory snapshot. A same-path repair retires the old directory only after staging and restores it when final rename or index publication fails. Verify exact indexed content on store open/index change and again when materializing runtime layers, returning the same verified `hooks.json` bytes to the parser. Cleanup old versions only after publication; cleanup failure leaves inactive residue and never rolls back or reactivates it. - [x] Make disable an index-only change. Make remove publish the index first and then delete only the removed record's exact digest directory while retaining the same cross-process lock. `ResetCorruptStore { scope }` is accepted only while that scoped store is corrupt and requires the caller's current combined revision. A corrupt store contributes an index-metadata/error marker to the revision; reset publishes a freshly seeded non-zero generation so old generation-zero plans cannot recur. - [x] Track the index metadata fingerprint so another local BitFun process causes one reload on the next access; do not enumerate bundle directories or scan external products. diff --git a/docs/superpowers/specs/2026-07-28-claude-codex-command-hook-import-design.md b/docs/superpowers/specs/2026-07-28-claude-codex-command-hook-import-design.md index afddd3c7da..9ccf097fd6 100644 --- a/docs/superpowers/specs/2026-07-28-claude-codex-command-hook-import-design.md +++ b/docs/superpowers/specs/2026-07-28-claude-codex-command-hook-import-design.md @@ -449,11 +449,15 @@ target generation; it is not persisted. ### Startup Process startup never discovers Claude Code/Codex sources and never re-imports -them. On first access to an import store, and only after its index metadata -changes, BitFun verifies the content of the exact indexed bundle paths against -their bounded private digests. It does not enumerate unreferenced version -directories or external product files. Normal Hook events check only index -metadata and reuse the existing `AgentHookEngine` until the generation changes. +them. On first access to an import store and after its index metadata changes, +BitFun verifies the content of the exact indexed bundle paths against their +bounded private digests. Whenever an `AgentHookEngine` must be rebuilt, runtime +layer loading also reads and verifies each bundle once and returns the same +verified `hooks.json` bytes to the native parser; it never trusts an earlier +in-memory validity bit for newly read commands. It does not enumerate +unreferenced version directories or external product files. Normal Hook events +still check only index metadata and reuse the existing engine until the imported +generation or a manual-file fingerprint changes. If the import index is unreadable or invalid, imported Hooks fail closed and a diagnostic is surfaced; manual BitFun Hooks continue to work. Recovery does not diff --git a/src/apps/cli/src/hook_import.rs b/src/apps/cli/src/hook_import.rs index 810c49da7e..0411cf40a8 100644 --- a/src/apps/cli/src/hook_import.rs +++ b/src/apps/cli/src/hook_import.rs @@ -198,7 +198,7 @@ async fn preview_or_apply( workspace, ExternalHookImportApplyRequestV1 { schema_version: EXTERNAL_HOOK_IMPORT_SCHEMA_V1, - source, + source: source.clone(), plan_fingerprint, }, ) @@ -206,11 +206,11 @@ async fn preview_or_apply( .map_err(operation_error)?; let text = match &result.outcome { bitfun_product_domains::external_hook_import::ExternalHookImportApplyOutcomeV1::Applied { - .. - } => "Imported Hooks are enabled and will apply on the next matching event.".to_string(), + snapshot, + } => completed_import_status(snapshot, &source, true).to_string(), bitfun_product_domains::external_hook_import::ExternalHookImportApplyOutcomeV1::Unchanged { - .. - } => "The reviewed Hook import is already current and enabled.".to_string(), + snapshot, + } => completed_import_status(snapshot, &source, false).to_string(), bitfun_product_domains::external_hook_import::ExternalHookImportApplyOutcomeV1::Stale { refreshed_plan, } => format!( @@ -221,6 +221,27 @@ async fn preview_or_apply( 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 and will apply on the next matching event." + } + (true, Some(false)) => "Imported Hooks were updated and remain disabled.", + (false, Some(true)) => "The reviewed Hook import is already current and enabled.", + (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, @@ -338,3 +359,68 @@ fn operation_error(error: ExternalSourceOperationError) -> anyhow::Error { 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::{ + 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." + ); + } +} diff --git a/src/apps/cli/src/modes/chat/external_hooks.rs b/src/apps/cli/src/modes/chat/external_hooks.rs index 4138c3f8c6..b438ff67e6 100644 --- a/src/apps/cli/src/modes/chat/external_hooks.rs +++ b/src/apps/cli/src/modes/chat/external_hooks.rs @@ -622,28 +622,29 @@ impl ChatMode { Some(workspace_root.as_path()), ExternalHookImportApplyRequestV1 { schema_version: EXTERNAL_HOOK_IMPORT_SCHEMA_V1, - source, + source: source.clone(), plan_fingerprint: plan.plan_fingerprint, }, ) .await?; - match result.outcome { + let (snapshot, applied) = match result.outcome { ExternalHookImportApplyOutcomeV1::Stale { refreshed_plan } => { - Ok(HookManagementResult::Plan(refreshed_plan)) - } - ExternalHookImportApplyOutcomeV1::Applied { snapshot } - | ExternalHookImportApplyOutcomeV1::Unchanged { snapshot } => { - let native = native_hook_overview(Some(workspace_root.as_path())).await; - Ok(HookManagementResult::Changed { - snapshot: HookManagementSnapshot { - native, - imports: snapshot, - }, - status: "Imported Hooks are enabled for the next matching event." - .to_string(), - }) + 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, diff --git a/src/crates/services/services-integrations/src/hook_import.rs b/src/crates/services/services-integrations/src/hook_import.rs index 833db9149b..ce97539773 100644 --- a/src/crates/services/services-integrations/src/hook_import.rs +++ b/src/crates/services/services-integrations/src/hook_import.rs @@ -408,10 +408,13 @@ impl HookImportStore { .filter(|record| record.enabled && record.bundle_valid) { let hooks_path = record.bundle_path.join("hooks.json"); - let bytes = match read_bounded(&hooks_path, MAX_HOOKS_FILE_BYTES as u64).await { - Ok(Some(bytes)) => bytes, - Ok(None) | Err(_) => continue, - }; + 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(), @@ -637,6 +640,16 @@ async fn validate_bundle_content( 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)? { @@ -663,7 +676,7 @@ async fn validate_bundle_content( if observed != expected_digest { return Err(HookImportStoreError::InvalidInput("bundle content digest")); } - Ok(()) + Ok(hooks_json) } async fn read_bundle_assets( @@ -1332,6 +1345,30 @@ mod tests { .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(); From 607ffbe8f3595db478798325f14c4af802d1744a Mon Sep 17 00:00:00 2001 From: limityan Date: Wed, 29 Jul 2026 08:29:41 +0800 Subject: [PATCH 3/3] fix(hooks): simplify import review feedback Remove process-only superpowers documents from the PR, keep the durable integrity rule in the owned architecture document, and make CLI/TUI/Desktop confirmation and state messaging reflect the actual Hook gates without exposing internal fingerprints. --- .../external-ai-work-sources-design.md | 4 +- ...-07-28-claude-codex-command-hook-import.md | 359 --------- ...claude-codex-command-hook-import-design.md | 709 ------------------ src/apps/cli/src/hook_import.rs | 54 +- src/apps/cli/src/modes/chat/external_hooks.rs | 2 +- src/apps/cli/src/modes/chat/native_hooks.rs | 2 +- src/apps/cli/src/modes/chat/tests.rs | 2 +- .../config/components/HooksConfig.test.tsx | 75 ++ .../config/components/HooksConfig.tsx | 21 +- .../src/locales/en-US/settings/hooks.json | 14 +- .../src/locales/zh-CN/settings/hooks.json | 14 +- .../src/locales/zh-TW/settings/hooks.json | 14 +- 12 files changed, 171 insertions(+), 1099 deletions(-) delete mode 100644 docs/superpowers/plans/2026-07-28-claude-codex-command-hook-import.md delete mode 100644 docs/superpowers/specs/2026-07-28-claude-codex-command-hook-import-design.md diff --git a/docs/architecture/extensions/external-ai-work-sources-design.md b/docs/architecture/extensions/external-ai-work-sources-design.md index 9f87ba8ff0..ad234bdd42 100644 --- a/docs/architecture/extensions/external-ai-work-sources-design.md +++ b/docs/architecture/extensions/external-ai-work-sources-design.md @@ -544,8 +544,8 @@ Command;明确缺失且未被标记失败的 Command 是稳定删除。产品 3. 验收覆盖脱敏序列化、部分失败、首次失败与空目录区分、非法/过大输入、有界枚举、provider 身份冲突、刷新竞态、stale 结果、保留命令、 `/help hooks`、GUI 空/错/刷新/不支持状态;Host 返回未知 v1 枚举或可执行字段时明确拒绝。 4. Claude Code/Codex 同步 command 导入通过私有准备、精确审阅、generation fencing 和不可变本地快照进入既有 - `AgentHookEngine`;OpenCode Hook 执行、外部 Runtime、持续监听及更复杂 handler 必须另立运行时切片,不能通过扩展 - 目录 DTO 偷渡执行语义。 + `AgentHookEngine`;托管快照在发布、重新打开及引擎重建读取时校验同一份有界内容摘要,校验失败即停止加载。 + OpenCode Hook 执行、外部 Runtime、持续监听及更复杂 handler 必须另立运行时切片,不能通过扩展目录 DTO 偷渡执行语义。 验收至少覆盖: diff --git a/docs/superpowers/plans/2026-07-28-claude-codex-command-hook-import.md b/docs/superpowers/plans/2026-07-28-claude-codex-command-hook-import.md deleted file mode 100644 index d9e29fe6e3..0000000000 --- a/docs/superpowers/plans/2026-07-28-claude-codex-command-hook-import.md +++ /dev/null @@ -1,359 +0,0 @@ -# Claude Code / Codex Command Hook Import Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: use `executing-plans` for inline execution or the repository-approved subagent workflow for task-by-task execution. Keep the implementation in one final commit and run a context-isolated adversarial review before any push or PR update. - -**Goal:** Let local CLI/TUI and Desktop users explicitly review, import, update, enable, disable, and remove the synchronous Claude Code/Codex command-Hook subset through BitFun's existing native Hook runtime. - -**Architecture:** Keep `AgentHookEngine` as the only executor and `ExternalHookCatalogCoordinator` as the only external Hook discovery owner. Add one command-bearing product-domain contract, one managed persistence service, and one core plan/apply orchestrator. Extend existing adapters and product surfaces; do not add a Hook SDK, generic import framework, second coordinator, second executor, watcher, or OpenCode runtime. - -**Tech stack:** Rust, Tokio, Serde, SHA-256, Clap/TUI, Tauri, React/TypeScript, i18next. - -**Approved design:** `docs/superpowers/specs/2026-07-28-claude-codex-command-hook-import-design.md` - -## Hard scope and interface limits - -These are implementation acceptance gates, not suggestions: - -1. Do not change `bitfun-agent-runtime` Hook parsing, dispatch, scheduling, payload, or outcome interfaces. Imported data must enter as ordinary `AgentHookSettingsLayer` values. -2. Do not add commands, bodies, environment values, asset bytes, or trust state to `ExternalHookCatalogSnapshotV1`; its current redacted `content_version` remains unchanged. -3. Do not introduce a generic importer, Hook manager hierarchy, HookBus, executor registry, plugin protocol, external watcher, or remote execution path. -4. Do not add a second JSON/TOML Hook traversal. Refactor `static-hook-support` once, keeping `parse_hook_document` as a compatibility wrapper. -5. Do not duplicate backend behavior in CLI or React. Both surfaces call the same core plan/apply/snapshot/mutation operations. -6. Do not share MCP-specific DTOs with Hook import. Reuse only its established preview/apply/stale interaction and the existing atomic JSON primitive. -7. The serialized public surface is limited to the types listed in Task 1. Any additional serialized DTO, fourth backend owner module, or change to OpenCode execution requires a design review before proceeding. -8. Do not add a new frontend state framework, stylesheet, or Hook-specific dialog component. Extend `HooksConfig.tsx` and reuse `Modal`, `ConfirmDialog`, `ConfigPageSection`, `Button`, and `Switch`. - -## File map - -### New production files - -| File | Single responsibility | -|---|---| -| `src/crates/contracts/product-domains/src/external_hook_import.rs` | Versioned local-only import DTOs plus non-serialized prepared adapter facts. | -| `src/crates/services/services-integrations/src/hook_import.rs` | Bounded managed bundle/index IO and in-memory store snapshot. | -| `src/crates/assembly/core/src/external_hook_import.rs` | Product path selection, plan/apply fencing, mutations, update checks, and native-layer projection. | -| `src/apps/cli/src/hook_import.rs` | Root CLI text/JSON projection over the shared core operations. | - -### Existing files to extend - -| Area | Files | -|---|---| -| Shared parsing | `src/crates/adapters/static-hook-support/src/lib.rs`, `src/crates/adapters/static-hook-support/tests/parser.rs` | -| Provider port/coordinator | `src/crates/contracts/product-domains/src/external_hook_catalog.rs`, `src/crates/assembly/external-sources/src/hook.rs`, `src/crates/assembly/external-sources/tests/hook_coordinator.rs` | -| Ecosystem conversion | `src/crates/adapters/codex-adapter/src/hook_source.rs`, `src/crates/adapters/codex-adapter/tests/hook_source.rs`, `src/crates/adapters/claude-code-adapter/src/hook_source.rs`, `src/crates/adapters/claude-code-adapter/tests/hook_source.rs` | -| Native runtime wiring | `src/crates/assembly/core/src/native_hooks.rs`, `src/crates/assembly/core/src/native_hooks_tests.rs`, `src/crates/assembly/core/src/lib.rs`, plus the existing `PathManager` native path digest visibility needed to isolate colliding workspace slugs | -| Cargo and boundary facts | `src/crates/services/services-integrations/Cargo.toml`, `scripts/core-boundaries/rules/feature-rules.mjs`, `scripts/core-boundaries/rules/source/public-api-rules.mjs` | -| CLI/TUI | `src/apps/cli/src/main.rs`, `src/apps/cli/src/actions.rs`, `src/apps/cli/src/modes/chat.rs`, `src/apps/cli/src/modes/chat/native_hooks.rs`, `src/apps/cli/src/modes/chat/external_hooks.rs`, `src/apps/cli/src/modes/chat/commands.rs`, `src/apps/cli/src/modes/chat/run.rs`, `src/apps/cli/src/modes/chat/tests.rs` | -| Desktop transport | `src/apps/desktop/src/api/external_hooks_api.rs`, `src/apps/desktop/src/api/remote_workspace_policy.rs`, `src/apps/desktop/src/lib.rs` | -| Web API/UI | `src/web-ui/src/infrastructure/api/service-api/ExternalHooksAPI.ts`, `src/web-ui/src/infrastructure/api/service-api/ExternalHooksAPI.test.ts`, `src/web-ui/src/infrastructure/config/components/HooksConfig.tsx`, new `HooksConfig.test.tsx` beside it | -| i18n/docs | `src/web-ui/src/locales/{en-US,zh-CN,zh-TW}/settings/hooks.json`, `docs/features/agent-hooks.md`, `docs/features/agent-hooks.zh-CN.md`, only the architecture sentences that still call Codex/Claude Hooks reference-only | - -No production change is planned under `src/crates/execution/agent-runtime` or the OpenCode adapter. - -## Stable operation surface - -Task 1 must implement only this serialized shape, with `camelCase`, `deny_unknown_fields`, bounded validation, schema version `1`, and redacted custom `Debug` wherever exact commands can appear: - -```rust -pub struct ExternalHookImportHandlerV1 { - 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, -} - -pub struct ExternalHookImportSkippedV1 { - pub reason_code: String, - pub count: u32, -} - -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, -} - -pub struct ExternalHookImportApplyRequestV1 { - pub schema_version: u32, - pub source: SourceKey, - pub plan_fingerprint: String, -} - -pub enum ExternalHookImportApplyOutcomeV1 { - Applied { snapshot: ExternalHookImportSnapshotV1 }, - Unchanged { snapshot: ExternalHookImportSnapshotV1 }, - Stale { refreshed_plan: ExternalHookImportPlanV1 }, -} - -pub struct ExternalHookImportApplyResultV1 { - pub schema_version: u32, - pub outcome: ExternalHookImportApplyOutcomeV1, -} - -pub struct ImportedHookSourceSnapshotV1 { - pub import_id: String, - pub source: ExternalHookSource, - pub enabled: bool, - pub behavior_version: String, - pub state: ImportedHookSourceStateV1, -} - -pub struct ExternalHookImportSnapshotV1 { - pub schema_version: u32, - pub revision: String, - pub catalog: ExternalHookCatalogSnapshotV1, - pub imports: Vec, - pub diagnostics: Vec, -} - -pub struct ExternalHookImportMutationRequestV1 { - pub schema_version: u32, - pub expected_revision: String, - pub action: ExternalHookImportMutationV1, -} - -pub enum ExternalHookImportMutationV1 { - SetEnabled { import_id: String, enabled: bool }, - Remove { import_id: String }, - ResetCorruptStore { scope: ExternalSourceScope }, -} -``` - -`ExternalHookImportDispositionV1`, `ExternalHookImportDependencyV1`, and `ImportedHookSourceStateV1` are closed enums used by these DTOs; do not add parallel summary/action types. The snapshot embeds the existing redacted catalog so CLI and Desktop do not invent different “available plus imported” merge contracts. The public `revision` is a stable hash of the user-store generation, workspace-store generation/state, and workspace identity, because one `u64` cannot fence two scope stores. Mutations return `ExternalHookImportSnapshotV1`; stale revisions use the existing `ExternalSourceOperationErrorCode::StaleRevision` rather than another result envelope. - -The non-serialized provider result is one `PreparedExternalHookImport` containing normalized handler facts, aggregated skip facts, and bounded asset bytes. It has a custom redacted `Debug`, computes one private `behavior_version`, and is not a persisted or user-authored format. - -## Task 1: Lock contracts and make one shared document walk - -**Files:** - -- Add `src/crates/contracts/product-domains/src/external_hook_import.rs`. -- Modify `src/crates/contracts/product-domains/src/external_hook_catalog.rs`. -- Modify `src/crates/contracts/product-domains/src/lib.rs`. -- Modify `src/crates/contracts/product-domains/tests/external_hook_catalog_contracts.rs`. -- Modify `src/crates/adapters/static-hook-support/src/lib.rs`. -- Modify `src/crates/adapters/static-hook-support/tests/parser.rs`. - -- [x] Add failing contract tests for exact JSON field names, schema rejection, unknown-field rejection, bounded command/diagnostic counts, invalid identifiers, stable SourceKey round trips, and redacted `Debug` output. -- [x] Add failing parser tests proving one traversal preserves current redacted results for JSON and TOML while exposing borrowed group/handler values only to a closure. -- [x] Introduce `visit_hook_document(bytes, format, max_handlers, visitor)` and a borrowed `StaticHookHandlerRef` in `static-hook-support`. The returned summary contains only `all_disabled`, structural issues, and `inspected_handlers`; it never retains raw `serde_json::Value` data. -- [x] Reimplement the existing `parse_hook_document` as a wrapper around `visit_hook_document`. Keep its signature, ordering, issue de-duplication, handler limits, and `redacted_parse_content_version` byte-for-byte compatible in tests. -- [x] Add `PreparedExternalHookImport` and the versioned DTOs above. Centralize prepared-fact validation and behavior hashing here so Codex and Claude adapters do not implement separate fingerprint logic. -- [x] Extend `ExternalHookSourceProvider` with one default `prepare_import(context, source, expected_catalog_content_version)` method returning the standard unsupported provider error. Do not add another provider trait. -- [x] Register the module under the existing `external-sources` feature; do not add a new product-domain feature. - -Run: - -```powershell -cargo test -p bitfun-product-domains --features external-sources external_hook -cargo test -p bitfun-static-hook-support -``` - -Expected: new tests fail before implementation, then pass; existing catalog snapshots and redacted content versions remain unchanged. - -## Task 2: Convert the conservative Codex and Claude command subset - -**Files:** - -- Modify both existing adapter `hook_source.rs` files and their existing tests. -- Modify `static-hook-support` only for shared static path recognition/asset collection needed by both adapters. - -- [x] Add Codex fixtures for user/project `hooks.json`, inline TOML, `commandWindows`, timeout/status, unsupported handler types, async/unknown behavior fields, unsupported events, malformed matchers, and command-only changes that keep catalog `content_version` stable but change private `behavior_version`. -- [x] Add Claude fixtures for `settings.json` and `settings.local.json`, missing `type`, `timeoutSec`, `disableAllHooks`, group/handler `if`, `async`, `asyncRewake`, `args`, `shell`, `once`, non-command types, unknown fields, and Claude-only events. -- [x] Implement both conversions through `visit_hook_document`; do not parse the same file again in an adapter-specific walker. -- [x] Preserve deterministic source/group/handler ordering and aggregate skipped reasons by stable reason code. -- [x] Add one shared conservative path helper for statically recognizable references under the source `hooks/` directory. Preserve unrecognized commands verbatim, reject dynamic source-root expressions, and identify absolute external dependencies for review. -- [x] Collect only referenced regular files. Reject links/reparse points, traversal, unreadable files, and budgets over 256 files, 1 MiB per file, 16 MiB total, or depth 8. Read asset bytes during preparation so the behavior digest and later write use the same bounded content. -- [x] Ensure no `CLAUDE_*`/`CODEX_*` environment, credentials, enablement, or trust data is copied. - -Run: - -```powershell -cargo test -p bitfun-codex-adapter hook_source -cargo test -p bitfun-claude-code-adapter hook_source -cargo test -p bitfun-static-hook-support -``` - -Expected: eligible handlers normalize to the current native field names; unsupported semantics remain visible only as skip counts/reasons; OpenCode code and tests do not change. - -## Task 3: Persist one bounded managed snapshot store - -**Files:** - -- Add `src/crates/services/services-integrations/src/hook_import.rs` with inline unit tests. -- Modify `src/crates/services/services-integrations/src/lib.rs` and `Cargo.toml`. -- Modify `src/crates/assembly/core/Cargo.toml` to enable the narrow service feature through `product-full`. - -- [x] Add focused tests for empty/valid restart-safe load, corrupt index fail-closed/reset without generation ABA, generation fencing, idempotent apply, invalid scope and management IDs, missing-bundle repair, same-path repair failure preserving the indexed bundle, pre-index-commit publication rollback, successful retired-bundle cleanup, refusal to re-enable a missing bundle, valid-but-modified Hook/asset rejection including same-process runtime loading, exact-version removal, and managed-ancestor link/reparse rejection. Fixed source-asset budgets stay covered by the shared preparation tests; atomic index replacement stays covered by `JsonFileStore`. -- [x] Add a `hook-import` service feature depending only on `bitfun-services-core`, `bitfun-product-domains/external-sources`, `bitfun-agent-runtime` for native document validation, `sha2`, `hex`, `thiserror`, and `uuid`. Add it to the existing `product-full` feature; do not reuse the broader `plugin-source` feature. -- [x] Implement `HookImportStore` with one root path supplied by assembly. Its private index contains only schema, a generation token that increments while valid and is freshly reseeded after corrupt reset, import identity, source key/kind/scope plus redacted display/location facts needed after restart, behavior version, active behavior-digest directory, exact managed-content digest, and enabled state. Do not persist the generation-sensitive review fingerprint, catalog diagnostics, exact review DTOs, source commands outside the managed native file, or foreign trust state. -- [x] Reuse `JsonFileStore::acquire_cross_process_lock` and `write_atomic_strict` for the index. Do not add another atomic JSON helper. Bound index reads before deserialization without expanding `JsonFileStore` unless a second real consumer is found. -- [x] Stage `hooks.json` and asset bytes in a sibling temporary directory before touching the indexed path, validate the generated layer with `AgentHookSettings::from_layers`, compute the exact managed-content digest, move to `bundles//`, atomically publish the index, then update the in-memory snapshot. A same-path repair retires the old directory only after staging and restores it when final rename or index publication fails. Verify exact indexed content on store open/index change and again when materializing runtime layers, returning the same verified `hooks.json` bytes to the parser. Cleanup old versions only after publication; cleanup failure leaves inactive residue and never rolls back or reactivates it. -- [x] Make disable an index-only change. Make remove publish the index first and then delete only the removed record's exact digest directory while retaining the same cross-process lock. `ResetCorruptStore { scope }` is accepted only while that scoped store is corrupt and requires the caller's current combined revision. A corrupt store contributes an index-metadata/error marker to the revision; reset publishes a freshly seeded non-zero generation so old generation-zero plans cannot recur. -- [x] Track the index metadata fingerprint so another local BitFun process causes one reload on the next access; do not enumerate bundle directories or scan external products. - -Run: - -```powershell -cargo test -p bitfun-services-integrations --no-default-features --features hook-import hook_import -``` - -Expected: every failed stage keeps the previous index and active bundle; no test writes outside its temporary managed root. - -## Task 4: Add plan/apply orchestration and feed the existing engine - -**Files:** - -- Add `src/crates/assembly/core/src/external_hook_import.rs` with inline unit tests. -- Modify `src/crates/assembly/core/src/external_hooks.rs`, `src/crates/assembly/core/src/native_hooks.rs`, `src/crates/assembly/core/src/native_hooks_tests.rs`, and `src/crates/assembly/core/src/lib.rs`. -- Modify `src/crates/assembly/external-sources/src/hook.rs` and its existing coordinator tests. - -- [x] Add focused tests for guarded preparation and redacted-version mismatch, provider-default unsupported behavior, private plan fingerprint fencing, native document compatibility, stable import IDs, missing-bundle recovery, deterministic native layer order, project imports independent of the manual project-file gate, next-generation cache replacement with an unchanged captured engine, and remote dispatch rejection. Keep source-missing/update-failure behavior fail-closed in the typed snapshot path without adding a second test-only coordinator. -- [x] Add a guarded synchronous preparation method to the existing `ExternalHookCatalogCoordinator`; it resolves the registered provider and current source and verifies the expected redacted catalog version. Wrap that method in a per-source in-flight `OnceCell` inside the existing core `WorkspaceExternalHookCatalogService`, using `spawn_blocking`, so simultaneous Desktop/TUI requests await the same preparation. Remove the cell after completion; do not cache completed prepared commands across requests because command-only changes do not change catalog `content_version`. -- [x] Expose these core functions and no second facade: - -```rust -pub async fn external_hook_import_snapshot( - workspace: Option<&Path>, - refresh_updates: bool, -) -> ExternalSourceOperationResult; - -pub async fn plan_external_hook_import( - workspace: Option<&Path>, - source: SourceKey, -) -> ExternalSourceOperationResult; - -pub async fn apply_external_hook_import( - workspace: Option<&Path>, - request: ExternalHookImportApplyRequestV1, -) -> ExternalSourceOperationResult; - -pub async fn mutate_external_hook_import( - workspace: Option<&Path>, - request: ExternalHookImportMutationRequestV1, -) -> ExternalSourceOperationResult; -``` - -- [x] Derive user/workspace store roots from the existing `PathManager`; pass roots down to the service. Reject remote workspaces before local file access. -- [x] Materialize the managed asset root before public review, serialize one native `hooks.json` from the same normalized handler facts, validate it with the current parser, and compute the plan fingerprint over source/catalog guard, private behavior version, fully materialized handler review, and current target generation. -- [x] On apply, refresh/guard the source, prepare again, rebuild the plan, compare the accepted fingerprint, and return `Stale { refreshed_plan }` without a write when anything changed. -- [x] Keep one bounded core cache of `Arc` by managed root so native dispatch, CLI, and Desktop share in-process generation/state. Do not create a generic import-store registry. -- [x] In `native_hooks::engine_for`, obtain the known imported layer snapshot asynchronously, then assemble this order: manual user, enabled imported user ordered by import ID, gated manual project, enabled imported workspace ordered by import ID. Add store generation/index fingerprint to `CachedHookEngine`; keep existing manual metadata fingerprints. -- [x] Keep an in-flight `Arc` unchanged. Mutations affect the next event only. Do not change command concurrency or outcome aggregation. -- [x] Extend `NativeHookOverview` construction to show imported files/rules through the same assembled paths; do not add a second imported-rule projection. - -Run: - -```powershell -cargo test -p bitfun-external-sources hook -cargo test -p bitfun-core external_hook_import -cargo test -p bitfun-core native_hook -node scripts/check-core-boundaries.mjs -``` - -Expected: manual Hooks still work when import state is absent/corrupt; a successful mutation invalidates exactly one cached generation; remote dispatch remains skipped. - -## Task 5: Make CLI/TUI the complete primary surface - -**Files:** - -- Add `src/apps/cli/src/hook_import.rs`. -- Modify the existing CLI/TUI files in the file map. - -- [x] Add focused Clap/TUI parser tests for preview-only import, confirmed destructive removal/reset, aliases, and the explicit second step for interactive writes; expose this complete command family: - -```text -bitfun hooks list [--refresh] [--format text|json] -bitfun hooks import --source [--confirm ] [--format text|json] -bitfun hooks update [--confirm ] [--format text|json] -bitfun hooks enable -bitfun hooks disable -bitfun hooks remove --confirm -bitfun hooks reset --confirm -``` - -- [x] Implement root commands as thin projections over the four core operations. `hooks list` renders `snapshot.catalog` plus `snapshot.imports`, including each catalog source's stable key. Import/update without a fingerprint is preview-only; a stale fingerprint prints/serializes the refreshed plan and exits without writing. Mutation staleness is returned to the user and never automatically replayed. Keep protocol stdout free of logs. -- [x] Replace the separate read-only TUI mental model with one `/hooks` renderer combining `NativeHookOverview`, `ExternalHookImportSnapshotV1`, and the existing redacted catalog. Keep `/hooks_external` and `/hooks-external` as compatibility aliases to the same management view. -- [x] Support `/hooks [refresh|import |update |enable |disable |remove |reset ]`. The first import/update invocation stores exactly one pending plan in `ChatMode` and renders full commands, Windows overrides, effective timeout, dependencies, skips, and fingerprint. Repeating the same action with `--confirm` applies that cached fingerprint; remove and corrupt-store-only reset also require `--confirm`. Do not add another confirmation popup or ask the user to type the long hash. -- [x] Replace the current Hook catalog receiver with one CLI-local Hook management result enum/receiver and one optional pending plan. Do not add parallel receivers for plan/apply/mutation. -- [x] Escape every source label, command, path, diagnostic, and error with the existing terminal escaping helper. Bound displayed lists while keeping JSON complete. -- [x] Update action help, command palette, asynchronous management refresh, and existing chat tests. Preserve old aliases and root command compatibility. - -Run: - -```powershell -cargo test -p bitfun-cli hooks -cargo check -p bitfun-cli -``` - -Expected: CLI is functionally complete without Desktop; every write requires explicit review confirmation; `/hooks_external` no longer exposes a separate implementation path. - -## Task 6: Add the Desktop transport and compact Settings UX - -**Files:** - -- Modify the Desktop and Web files in the file map; add only `HooksConfig.test.tsx`. - -- [x] Add Desktop tests for structured camelCase requests, nested core requests, unknown-field rejection, and explicit `Reject` remote policies for snapshot/plan/apply/mutate commands; retain the existing command-registration contract coverage. -- [x] Extend `external_hooks_api.rs` with four Tauri commands that map directly to the four core operations. Keep the existing catalog command and file; do not add another API module. -- [x] Extend `ExternalHooksAPI.ts` with exact bounded validators for the v1 DTOs. Reuse `invokeExternalSourceCommand`, `exactRecord`, `boundedString`, SourceKey parsing, and common error handling; do not create a second client service. -- [x] Add component tests for the highest-risk interactions: parallel initial load plus exact command/skipped-reason review before apply; stale plan replacement without implicit apply; stale mutation refresh without action replay; and destructive removal only after a source-preservation confirmation. Keep remaining state transitions thin projections over the closed, validator-tested API. -- [x] Extend `HooksConfig.tsx` with one imported-source section and one reused `Modal` for plan review. Reuse `ConfirmDialog` for remove/reset. Keep the two existing switches and make the master switch gate imported execution without deleting state. -- [x] Load discovery only when the Hooks settings page mounts or the user refreshes. Do not poll after unmount and do not add a file watcher. -- [x] Add only owner-namespace strings to `settings/hooks.json` for `en-US`, `zh-CN`, and `zh-TW`; preserve product names as interpolated/provider display facts rather than hard-coded translated brand variants. - -Run: - -```powershell -pnpm --dir src/web-ui run test:run src/infrastructure/api/service-api/ExternalHooksAPI.test.ts src/infrastructure/config/components/HooksConfig.test.tsx -pnpm run i18n:contract:test -pnpm run type-check:web -cargo check -p bitfun-desktop -``` - -Expected: the common path is “review once, then enabled”; only arbitrary-code import/update and destructive remove/reset require confirmation. - -## Task 7: Documentation, focused verification, and adversarial scope review - -**Files:** - -- Modify `docs/features/agent-hooks.md` and `docs/features/agent-hooks.zh-CN.md`. -- Modify only architecture sentences that still describe Claude/Codex Hooks as permanently reference-only. -- Update this plan's checkboxes during execution; do not add another implementation spec. - -- [x] Document import destination, user/workspace scope, enable/disable/remove, no startup re-import, no watcher, explicit update review, next-event hot reload, actual deterministic layer order, external dependency behavior, remote unsupported state, and OpenCode runtime deferral. -- [x] Run `pnpm run fmt:rs` and `git diff --check`. -- [x] Run all focused commands from Tasks 1-6, then `cargo check --workspace`, `pnpm run i18n:audit`, and `pnpm run check:repo-hygiene`. -- [x] Inspect `git diff --stat` and the public API diff. Stop and redesign if the implementation added a fourth backend owner, a second parser/executor/coordinator, a generic import abstraction, an unlisted serialized DTO, OpenCode runtime behavior, watcher code, or agent scheduling changes. -- [x] Search for duplicated conversions and surface logic: - -```powershell -rg -n "prepare_import|behavior_version|plan_fingerprint|visit_hook_document|HookImportStore" src/crates src/apps/cli/src src/web-ui/src -``` - -Expected: one provider port, one shared document walk, one behavior-version implementation, one store, one core plan/apply owner, and thin surfaces. - -- [x] Before any push, spawn a context-isolated subagent to adversarially review the entire diff against the approved design, `AGENTS.md`, architecture boundaries, command-execution safety, atomicity, performance, i18n, and the explicit non-goals. Fix every actionable finding and rerun affected checks. -- [x] Rebase onto latest `gcwing/main`, resolve conflicts without broad rewrites, rerun affected focused checks, and repeat the adversarial diff review if the rebase changes behavior. -- [x] Squash/amend the design, plan, implementation, fixes, and documentation into one accurate final commit. Push only to `limityan/BitFun`; never push directly to `GCWing/BitFun`. Do not wait for CI unless explicitly requested, but do not claim pending/unreported checks passed. - -## Completion evidence - -The PR is ready only when the handoff can show: - -- one CLI import/update transcript proving preview, stale rejection, apply, disable, and remove; -- one Desktop component test proving exact command review and shared backend state; -- one restart/load test proving no external rediscovery or re-import; -- one next-event generation test proving hot reload without changing an in-flight engine; -- one atomic failure test proving the old version remains active; -- one unsafe asset test for links/reparse points and budget rejection; -- no changes to OpenCode execution, Agent Hook scheduling, or the redacted catalog DTO; -- a clean, context-isolated adversarial review after the final diff. diff --git a/docs/superpowers/specs/2026-07-28-claude-codex-command-hook-import-design.md b/docs/superpowers/specs/2026-07-28-claude-codex-command-hook-import-design.md deleted file mode 100644 index 9ccf097fd6..0000000000 --- a/docs/superpowers/specs/2026-07-28-claude-codex-command-hook-import-design.md +++ /dev/null @@ -1,709 +0,0 @@ -# Claude Code / Codex command Hook import design - -## Status and baseline - -Status: approved for implementation planning. - -This design is based on the following current sources as of 2026-07-28: - -- BitFun `gcwing/main` at `9f705a41c`; -- Codex `openai/codex` `main` at `e597169e9a`; -- OpenCode `anomalyco/opencode` `dev` at `017a5977d2`; -- the current [Codex Hooks reference](https://developers.openai.com/codex/hooks) and - [Claude Code Hooks reference](https://code.claude.com/docs/en/hooks). - -The implementation belongs in one focused PR. It makes the already-discovered, -compatible Claude Code and Codex command Hooks usable in BitFun without waiting -for the JavaScript Plugin Host. It does not implement OpenCode Hook execution or -create a general Hook SDK. - -## Problem - -BitFun currently has two separate, valid pieces: - -1. `bitfun-agent-runtime::native_hooks::AgentHookEngine` executes BitFun's - Codex-compatible `hooks.json` command handlers. Product wiring in - `assembly/core/src/native_hooks.rs` reads the user and project files and - dispatches the eleven supported lifecycle events. -2. The external Hook catalog discovers Claude Code, Codex, and OpenCode Hook - declarations through ecosystem adapters. It is intentionally read-only and - redacts handler bodies, commands, environment data, and credentials. - -Consequently, `/hooks_external` can tell a user that compatible command Hooks -exist, but using them still requires manually copying and rewriting source -configuration. Manual copying has four concrete problems: - -- BitFun cannot distinguish imported declarations from hand-written BitFun - Hooks, so disabling, updating, or removing one source is unsafe; -- source-relative scripts and product-specific variables may stop resolving; -- unsupported Claude Code fields can be silently misunderstood; -- there is no version-fenced review between discovering arbitrary commands and - enabling their execution. - -The product goal is therefore narrow: let a local user explicitly review, -import, enable, update, disable, and remove the synchronous command-Hook subset -that the existing BitFun engine can execute faithfully. - -## Decision summary - -The first version uses a **BitFun-managed import snapshot**: - -- external files remain read-only and are never mounted as live runtime config; -- an import writes a separate BitFun-owned native `hooks.json` layer plus only - the source-owned Hook assets that can be copied and rewritten safely; -- discovery and import planning are on demand and asynchronous; -- startup never re-imports a source and no persistent external file watcher is - installed; -- import/update is an atomic, version-fenced plan/apply operation; -- one source-level switch controls the imported snapshot; removal touches only - BitFun-owned files; -- a successful mutation invalidates the current Hook engine generation and is - effective for the next lifecycle event, without changing in-flight Hooks; -- imported command layers reuse the current native Hook dispatch and execution - path; -- OpenCode remains a static catalog until its JavaScript Hook execution is - delivered through `PluginRuntimeClient`. - -No public concept named "bundle" or "manifest" is introduced. Product surfaces -say "Imported from Claude Code" or "Imported from Codex". - -### Alternatives rejected - -| Approach | Why it is not v1 | -| --- | --- | -| Merge into the user's existing `hooks.json` | Loses provenance and makes source-level update, disable, and removal unsafe. | -| Read external files directly at runtime | Creates two live truth sources, source-product trust ambiguity, and startup/event-path IO. | -| Managed snapshot (selected) | Adds one small private store, but gives stable review, rollback, and lifecycle behavior without a new runtime. | - -## Goals - -- Preserve one Hook lifecycle owner and one command execution implementation. -- Make the imported source, scope, exact commands, and skipped items reviewable - before arbitrary code is enabled. -- Make disable and removal reversible and source-scoped. -- Avoid adding work to ordinary startup and Agent-event hot paths beyond loading - already-enabled BitFun-owned layers. -- Give CLI/TUI and Desktop the same typed plan/apply and lifecycle operations. -- Fail without changing the last active snapshot when discovery, conversion, or - persistence fails. - -## Non-goals - -This PR does not add: - -- live mounting or continuous watching of `.claude` or `.codex` files; -- automatic updates after an external source changes; -- OpenCode JavaScript Hook execution; -- Claude Code `http`, `mcp_tool`, `prompt`, or `agent` handlers; -- Claude Code async handlers, `asyncRewake`, `args`, custom `shell`, `if`, or - component-frontmatter Hooks; -- Codex plugin-bundled or administrator-managed Hooks; -- a generic Hook SDK, HookBus, executor registry, or new plugin protocol; -- remote-workspace Hook execution; -- per-handler editing, reordering, or policy overrides; -- migration of credentials, environment variables, or external product trust - records. - -## Current implementation constraints - -### Native command Hooks - -The current native owner already supplies the required execution primitive: - -- user file: `/config/hooks.json`; -- project file: `/.bitfun/config/hooks.json`; -- global master gate: `app.hooks.enabled`; -- manual project-file gate: `app.hooks.project_hooks_enabled`; -- local-only execution, with all Hooks skipped for remote workspaces; -- bounded files, handler count, timeouts, captured output, and model-visible - output; -- Tokio child processes with JSON stdin and bounded stdout/stderr; -- a per-workspace engine cache rebuilt when configured file fingerprints change. - -The cache currently checks manual file metadata at every dispatch and reads a -changed file synchronously while rebuilding. The import design must not add -external source scans or imported-directory enumeration to that event path. - -The current engine runs matching handlers sequentially and stops launching later -handlers after a final blocking outcome. Current Codex and Claude Code run all -matching command Hooks concurrently. That is an existing native compatibility -gap, not created by import. This PR must document the actual BitFun ordering but -must not combine import work with a scheduler and outcome-aggregation rewrite. - -### External Hook catalog - -`ExternalHookCatalogCoordinator` already supplies bounded, coalesced background -discovery with last-valid snapshots. Claude Code, Codex, and OpenCode adapters -own native file discovery and syntax interpretation. - -`ExternalHookCatalogSnapshotV1` must remain a safe summary. It must not be -expanded to carry commands simply because import needs them. Import preparation -is a separate, explicit operation with a narrower local consumer. - -Its current `content_version` is also deliberately computed from redacted -catalog facts, so changing only a command body does not change that value. It -must remain a catalog-generation guard and must not be reused as an import -update signal. Explicit import preparation computes a separate private -`behavior_version` over the complete normalized import behavior. - -### Architecture guardrails - -The repository architecture requires one Hook Coordinator. Source adapters may -interpret native declarations, while deadline, cancellation, permission -effects, event ordering, and outcome aggregation remain in the Agent owner. The -first import implementation therefore extends the inputs assembled by the -existing `native_hooks` wiring; it does not create a parallel runtime owner. - -## Product behavior - -### Unified mental model - -`/hooks` and **Settings -> Agent Hooks** become the single management surface: - -```text -Agent Hooks - Active - BitFun user Hooks - Imported from Claude Code Enabled - Available to import - Codex project Hooks 3 compatible, 1 skipped - Needs attention - Claude Code source changed Review update -``` - -The only normal workflow is: - -```text -Detect -> review exact commands and skipped reasons -> import and enable -``` - -Afterwards the user manages the source with `Update`, `Disable/Enable`, or -`Remove`. Import and enable is one confirmed action; there is no second -activation ceremony. - -`/hooks_external` remains as a compatibility alias that opens or renders the -external/available section of `/hooks`. It no longer represents a separate -product concept. - -### Scope behavior - -- User-global Claude Code or Codex configuration imports into a user-global - BitFun layer and applies to local workspaces. -- Project or local-project configuration imports into a BitFun-managed layer - associated with that local workspace. -- Import scope follows the source scope and is not user-remappable in v1. -- `app.hooks.enabled` remains the master switch for manual and imported Hooks. -- `app.hooks.project_hooks_enabled` continues to gate only the repository-owned - `.bitfun/config/hooks.json` file. A reviewed imported workspace snapshot has - its own source-level enabled state, so importing it does not silently enable - unrelated repository-owned BitFun Hooks. - -### Additive behavior and conflicts - -Hook declarations are additive; two different commands on the same event and -matcher are not a name conflict and both remain visible and executable. The -design does not add a main selector or a conflict-resolution UI. - -Exact repeated declarations are shown as duplicates in the review, but v1 does -not silently change their execution semantics across independently imported -sources. This avoids choosing an arbitrary provider as owner and matches the -current BitFun layer model. A user can disable the duplicate source as one unit. - -## Import compatibility - -### Codex - -Supported sources: - -- user and project `hooks.json`; -- user and project inline `[hooks]` tables in `config.toml`. - -Supported declarations are the eleven event names already implemented by -`AgentHookEvent`, with `type = "command"` handlers containing only fields the -BitFun engine currently honors: - -- `command`; -- `commandWindows` / `command_windows`; -- `timeout`; -- `statusMessage`. - -`prompt`, `agent`, async execution, `additionalContextLimit`, plugin-bundled -Hooks, managed Hooks, and unknown behavior-affecting fields are skipped with an -explicit reason. Codex trust and per-Hook enablement records are not copied; -confirmation of the exact BitFun import plan is a new BitFun-local decision. - -### Claude Code - -Supported sources: - -- `~/.claude/settings.json`; -- `/.claude/settings.json`; -- `/.claude/settings.local.json`. - -Conversion follows the conservative subset used by Codex's current Claude Hook -migration: - -- a missing handler `type` is normalized to `command`; -- `timeoutSec` is normalized to `timeout`; -- synchronous command handlers with `command`, optional timeout, and optional - `statusMessage` are eligible; -- group-level or handler-level `if`, unknown group fields, `async`, - `asyncRewake`, `args`, `shell`, `once`, and non-command handler types are - skipped; -- `disableAllHooks: true` is respected and produces no eligible import. - -Only events implemented by BitFun are considered. Newer Claude-only events such -as `Setup`, `PostToolUseFailure`, `Notification`, task/team events, worktree -events, file/config change events, and MCP elicitation events remain visible in -the static catalog but are not importable. - -### Commands and source-owned assets - -Import does not attempt to parse arbitrary shell syntax or emulate another -product's complete environment. - -- Commands without a known source-relative Hook path are preserved verbatim. -- Statically recognizable references under the source's `hooks/` directory may - be copied into the managed snapshot and rewritten to that copied location. -- Only regular files inside the source Hook directory are copied. Links, - reparse points, escapes outside the source root, unreadable files, and copies - exceeding fixed implementation budgets make the affected handler ineligible. -- Dynamic source-root expressions that cannot be rewritten safely are skipped. -- Absolute dependencies outside the source Hook directory are not copied; they - remain explicit external dependencies in the review. -- BitFun does not inject `CLAUDE_*` or `CODEX_*` compatibility variables and - never copies credentials or environment values. - -The copied asset budgets are internal safety constants, not user-facing -configuration. V1 uses at most 256 regular files, 1 MiB per file, 16 MiB total, -and eight directory levels. These match the existing managed-package byte -budgets where applicable and must be covered by tests. - -## Persistent representation - -Imported Hooks are product-owned runtime data, not user-authored configuration -and not repository content. - -User-global imports live below: - -```text -PathManager::user_data_dir()/hook-imports/ -``` - -Workspace imports live below: - -```text -PathManager::project_runtime_root(workspace)/hook-imports/ -``` - -Each root contains one private `index.json` and versioned bundle directories: - -```text -hook-imports/ - index.json - bundles/// - hooks.json - hooks/... -``` - -The private index stores only fields needed by a real consumer: - -- schema version and a generation token that increments while the index is - valid and is freshly reseeded after explicit corrupt-store reset; -- stable import id, provider id, source key, source kind/scope, and the - catalog's redacted display/location facts needed after restart; -- imported private behavior version and a digest of the exact managed - `hooks.json` plus asset bytes; -- active bundle version and enabled state. - -There is no public import file format and no compatibility promise for the -directory layout. Product surfaces consume typed snapshots and actions, never -read the index directly. - -### Atomic writes - -Import and update use the following order: - -1. prepare and validate the complete new bundle in a sibling temporary - directory without touching the indexed path; -2. parse its `hooks.json` with the existing `AgentHookSettings` parser; -3. move it to a new immutable version directory; when repairing the same - behavior path, first retire the old directory and restore it if the final - rename fails; -4. atomically replace `index.json` so it points at the new version and increments - generation; if index publication returns an error, remove the newly published - bundle and restore the retired indexed directory before releasing the store lock; -5. publish the new in-memory registry snapshot; -6. remove only the old unreferenced digest directory on a best-effort cleanup - path while the same per-store lock is still held. - -If steps 1-3 fail, the old index and active bundle are unchanged. A handled step-4 -failure rolls the bundle path back to match the old index. A process crash between -bundle and index publication can leave residue or a digest mismatch; bounded -verification on the next open fails that imported layer closed. If old-version -cleanup fails after step 4, the old directory is inactive residue; it is never -reactivated implicitly and can be removed by a later maintenance pass. - -Disable changes only the indexed enabled state. Remove first removes the index -record and publishes the new generation, then deletes only the digest directory -named by that removed record while the same per-store lock is held. It never -deletes a sibling version that another process may have published, and never -edits or deletes the Claude Code/Codex source. A removed source can be -re-imported later. - -## Typed operations and ownership - -### Contracts - -The Hook domain adds the minimum plan/apply DTOs needed by CLI and Desktop: - -- `ExternalHookImportPlanV1` with source identity/version, current disposition, - exact eligible command summaries, skipped items with reason codes, and a plan - fingerprint; -- `ExternalHookImportApplyRequestV1` with source key and accepted fingerprint; -- `ExternalHookImportApplyResultV1`, returning applied, unchanged, or stale with - a refreshed plan; -- `ImportedHookSourceSnapshotV1` and closed enable/disable/remove actions, plus - a corrupt-store-only reset action scoped to the affected user/workspace - store. - -The management snapshot embeds the existing redacted catalog and exposes one -combined revision over the user and workspace stores. Mutations fence that -revision instead of inventing separate surface actions for each store. A stale -mutation is never replayed automatically: Desktop refreshes its visible state, -while CLI/TUI reports staleness and requires an explicit refresh/retry. The user -must trigger enable, disable, remove, or reset again against the new revision. -Corrupt reset chooses a fresh non-zero generation token so a pre-reset plan or -mutation cannot become valid again through ABA. - -Command-bearing DTOs require redacted `Debug` implementations and must never be -written to logs or exposed through remote/peer APIs. Exact commands are shown -only because local users must review the code BitFun is about to execute. - -The existing catalog DTO remains unchanged and redacted. - -Interface stability is protected by three limits: - -- the public serialized surface contains one versioned plan, apply request and - result, imported-source snapshot, and one closed source mutation action; -- provider-to-assembly prepared data is non-serialized and has redacted - `Debug`; it does not become a user configuration format; -- there is no generic import manager, import registry, Hook executor trait, or - second management state model shared with MCP merely because both use a - review/apply interaction. - -### Source adapters - -`ExternalHookSourceProvider` gains a default `prepare_import` operation keyed by -source key and expected redacted catalog version. The default result is -unsupported, so OpenCode and future catalog-only providers do not acquire -execution behavior. The Claude Code and Codex adapters implement it by rereading -the selected source and producing a prepared native layer, a private -`behavior_version`, and asset copy facts. - -Both implementations extend the existing `static-hook-support` document walk. -The current redacted `parse_hook_document` remains a compatibility wrapper over -that single walk; import conversion receives borrowed group/handler objects -through a closure and does not introduce a second JSON/TOML traversal or copy -raw handler values into the catalog result. - -Adapters own only native syntax, source precedence, conservative conversion, -and rewrite diagnostics. They do not write BitFun state or enable execution. - -The OpenCode provider keeps the default unsupported preparation result. Its -JavaScript callbacks are not converted into commands. - -### Durable store and assembly - -- `services-integrations` owns a small `HookImportStore`: containment checks, - bounded asset persistence, index/bundle persistence, and cleanup. It reuses - `services-core::JsonFileStore::write_atomic_strict` for the index instead of - adding another atomic JSON writer. -- `assembly/external-sources` continues to own coalesced Hook discovery and the - current provider generation. -- a focused `assembly/core::external_hook_import` module follows the existing - external MCP import plan/apply pattern: build plan, re-prepare on apply, - compare fingerprints, call the store, and return typed outcomes. -- `assembly/core::native_hooks` remains the product Hook dispatch owner. It adds - enabled imported paths to the current native layer list and includes the - import generation in its engine cache key. -- `bitfun-agent-runtime` keeps the same parser, engine, payload, and outcome - types; it receives ordinary `AgentHookSettingsLayer` values and remains free - of filesystem/import/provider dependencies. - -This reuses plan/apply and generation-fencing logic structurally without -sharing MCP-specific data types or adding Hook to the generic external-source -control object. - -The immutable bundle directory is named from the private behavior digest, not -the accepted plan fingerprint. A separate private content digest covers the -exact generated `hooks.json`, sorted relative asset paths, and asset bytes. This -keeps the final managed asset path known before the review DTO is rendered, -avoids a fingerprint that contains its own path, and lets a restart reject a -syntactically valid but modified snapshot. The plan fingerprint covers the -fully materialized commands, behavior digest, source/catalog guard, and current -target generation; it is not persisted. - -## Runtime loading and performance - -### Startup - -Process startup never discovers Claude Code/Codex sources and never re-imports -them. On first access to an import store and after its index metadata changes, -BitFun verifies the content of the exact indexed bundle paths against their -bounded private digests. Whenever an `AgentHookEngine` must be rebuilt, runtime -layer loading also reads and verifies each bundle once and returns the same -verified `hooks.json` bytes to the native parser; it never trusts an earlier -in-memory validity bit for newly read commands. It does not enumerate -unreferenced version directories or external product files. Normal Hook events -still check only index metadata and reuse the existing engine until the imported -generation or a manual-file fingerprint changes. - -If the import index is unreadable or invalid, imported Hooks fail closed and a -diagnostic is surfaced; manual BitFun Hooks continue to work. Recovery does not -delete data automatically. A destructive reset is offered only from the error -state and requires explicit confirmation. - -### Discovery and update checks - -External discovery runs only when: - -- the user opens `/hooks` or Agent Hooks settings; -- the user explicitly refreshes; -- an import/update operation needs a fresh plan. - -It reuses the current `ExternalHookCatalogCoordinator` discovery lane, so -simultaneous Desktop/TUI requests coalesce and blocking filesystem reads remain -off the UI thread. Concurrent preparation for the same source/version also -coalesces. Closing the view discards late UI results and does not leave a poller -or watcher behind. - -Catalog refresh alone produces only source presence and redacted structure. For -an already imported source, the management refresh asynchronously calls guarded -import preparation and compares its private `behavior_version` with the index. -That produces `current`, `update available`, `source missing`, or `update check -failed`. This work runs only in the management/explicit-refresh path and is -coalesced per source. A changed source never changes execution until the user -reviews and applies a new plan. - -### Runtime hot reload - -Import, update, enable, disable, and remove atomically publish a new import -generation. `native_hooks::engine_for` includes that generation and the known -manual-file fingerprints in its cache comparison. The next lifecycle event -rebuilds the engine once and reads the new immutable imported layer set. - -An event that already captured an `Arc` completes with that -engine. In-flight child processes are neither killed nor reconfigured by a -management action. - -External file edits are not runtime hot reload. They appear as `update -available` after an on-demand refresh. - -## Layer order and permission behavior - -Native layers are assembled in deterministic order: - -1. manual user `hooks.json`; -2. enabled user-global imported sources ordered by stable import id; -3. manual project `.bitfun/config/hooks.json`, when its existing gate is on; -4. enabled imported workspace sources ordered by stable import id. - -The order is visible in `/hooks`. The implementation does not add source -priority editing. - -Imported Hooks have exactly the current BitFun command-Hook authority: - -- they execute as the local BitFun user; -- a Hook can narrow permission but cannot widen a rule-based denial; -- modified tool input must continue through the owning validation and permission - path; -- post-tool feedback cannot undo a completed side effect; -- remote workspaces skip all Hook dispatch rather than running locally against a - remote path. - -No import action establishes plugin, MCP, tool, or OpenCode execution approval. - -## CLI and Desktop surfaces - -### Interactive CLI/TUI - -`/hooks` becomes asynchronous and renders the last current native/import state -immediately while external discovery refreshes in the background. It supports: - -```text -/hooks -/hooks refresh -/hooks import [--confirm] -/hooks update [--confirm] -/hooks enable -/hooks disable -/hooks remove --confirm -/hooks reset --confirm -``` - -Import/update first renders: - -- source and scope; -- every exact command that will execute, with effective timeout and Windows - override where applicable; -- copied or external asset dependencies; -- skipped event/handler count and concise reason for each class; -- the plan fingerprint. - -The existing interactive confirmation accepts that exact plan. Terminal text is -escaped through the existing CLI diagnostic helpers. - -### Root CLI - -For scripting and non-interactive use, add the matching root command family: - -```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 -``` - -Without `--confirm `, import is preview-only. Non-interactive -execution never approves the plan it just discovered. A stale confirmation -returns the refreshed plan and performs no write. - -Reset is exposed only for explicit recovery of a corrupt BitFun-managed scope. -It requires confirmation and leaves the Claude Code/Codex source intact. - -### Desktop - -The current **Agent Hooks** settings page keeps its two existing global/manual -project switches and adds one compact imported-source section. It uses the same -plan/apply APIs as CLI and does not parse source files in React. - -The review dialog shows exact commands and skipped reasons before import or -update. Enable/disable is immediate; remove confirms that only the BitFun copy -will be deleted and the source application is unchanged. - -All new copy uses the owning settings namespace and the repository i18n flow. - -## Failure behavior - -| Failure | Behavior | -| --- | --- | -| External discovery fails | Keep current imports active; show stale/failed discovery. | -| Source changes after review | Return `stale` with a refreshed plan; write nothing. | -| Import state changes during enable/disable/remove/reset | Refresh visible state, report stale, and require the user to trigger the action again. | -| Source is invalid or has no compatible handlers | Show skip reasons; do not create an empty import. | -| Asset cannot be copied safely | Skip the affected handler; never follow an escaping link. | -| Bundle validation or persistence fails | Keep the previous active version and generation. | -| Indexed `hooks.json` or asset content no longer matches its digest | Fail that imported bundle closed; update republishes the reviewed snapshot. | -| Enable finds a missing/corrupt bundle | Stay disabled and require update or removal. | -| Source disappears later | Continue using the snapshot; show `source missing`; allow disable/remove. | -| Old bundle cleanup fails | New state remains authoritative; inactive residue is never selected again. | -| A managed path ancestor is a link or Windows reparse point | Reject the operation without reading or deleting through it. | -| Import index is corrupt | Disable imported layers only; manual Hooks continue; offer explicit reset. | -| Corrupt index is explicitly reset | Publish an empty index with a fresh generation token; pre-reset plans remain stale. | -| Workspace is remote | Return a clear unsupported state; never execute locally as a fallback. | - -## OpenCode consistency boundary - -Claude Code and Codex command Hooks become native layers before runtime, so they -reuse the existing Hook lifecycle dispatch and command backend. OpenCode Hooks -are JavaScript callbacks that can mutate input/output objects and receive an -OpenCode plugin context; they require the plugin execution domain. - -The consistent architecture is therefore: - -```text -Claude/Codex source adapters --explicit import--> native command layers - | -BitFun native hooks.json ---------------------------+--> current native_hooks owner - | + AgentHookEngine -OpenCode adapter --future approved declarations----+--> future PluginRuntimeClient backend -``` - -Consistency means one lifecycle owner, one permission revalidation path, and -one aggregate outcome policy. It does not mean one source schema or one physical -executor. - -The PR does not expand `ExternalHookPoint` beyond the events consumed by a real -OpenCode runtime, does not add a generic executor trait in anticipation of that -runtime, and does not claim the existing OpenCode catalog is executable. - -## Verification - -Minimum automated coverage: - -- adapter fixtures for Codex JSON/TOML and Claude user/project/local sources; -- table-driven eligible/skipped cases for every supported and explicitly - unsupported field/type/event class; -- path rewrite, link/reparse escape, copy-budget, and containment tests on the - platforms where those rules differ; -- plan fingerprint and stale-apply tests; -- atomic import/update failure tests proving the previous bundle remains active; -- enable, disable, remove, missing source, missing bundle, and corrupt index - lifecycle tests; -- engine layer-order and generation-invalidation tests; -- remote-workspace skip tests; -- CLI text/JSON contract tests and interactive command tests; -- Desktop API serialization, remote policy declaration, component behavior, - accessibility, and i18n checks. - -The implementation PR should run the smallest matching checks, at minimum: - -```text -cargo test -p bitfun-agent-runtime native_hook -cargo test -p bitfun-external-sources hook -cargo test -p bitfun-codex-adapter hook -cargo test -p bitfun-claude-code-adapter hook -cargo test -p bitfun-cli hooks -cargo check --workspace -node scripts/check-core-boundaries.mjs -pnpm run i18n:contract:test -pnpm run type-check:web -``` - -Documentation updates in the implementation PR: - -- update `docs/features/agent-hooks.md` and its Chinese counterpart with import, - lifecycle, no-live-sync, and actual ordering behavior; -- update CLI help and Settings copy; -- update architecture documents only where they currently say Codex/Claude are - reference-only, without restating this entire feature design. - -## Acceptance criteria - -The slice is complete when all of the following are true: - -1. A local CLI user can discover a Claude Code or Codex source, review exact - compatible commands and skipped reasons, confirm one version-fenced plan, - and have the imported source run on the next matching BitFun Hook event. -2. The same imported source and actions are visible in Desktop Agent Hooks - settings through the same backend state and operations. -3. Disable takes effect on the next event; remove deletes only BitFun-owned data; - re-import does not duplicate the source record. -4. Restart loads enabled snapshots without rediscovering or re-importing the - external products. -5. Editing an external source does not change runtime behavior until a reviewed - update is applied. -6. A stale plan, failed write, unsafe asset, corrupt bundle, or remote workspace - cannot silently execute new code. -7. OpenCode Hook execution and all runtime-dependent handler kinds remain - unchanged and explicitly unsupported. - -## Scope stop conditions - -The implementation must stop and return to design review if it begins to -require any of the following: - -- a general-purpose Hook SDK or third-party executor registry; -- JavaScript module loading or Plugin Host lifecycle changes; -- persistent external file watchers; -- migration of secrets, permissions, or foreign trust databases; -- remote Hook execution; -- per-handler editing or source-priority policy; -- changes to Agent loop scheduling unrelated to loading imported layers. - -These are separate capabilities, not prerequisites for the command-Hook import -user outcome. diff --git a/src/apps/cli/src/hook_import.rs b/src/apps/cli/src/hook_import.rs index 0411cf40a8..9eb0d9ee78 100644 --- a/src/apps/cli/src/hook_import.rs +++ b/src/apps/cli/src/hook_import.rs @@ -233,10 +233,12 @@ pub(crate) fn completed_import_status( .map(|item| item.enabled); match (applied, enabled) { (true, Some(true)) => { - "Imported Hooks are enabled and will apply on the next matching event." + "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 already current and enabled.", + (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.", } @@ -296,7 +298,7 @@ fn render_snapshot(snapshot: &ExternalHookImportSnapshotV1) -> String { lines.join("\n") } -pub(crate) fn render_plan(plan: &ExternalHookImportPlanV1) -> String { +fn render_plan_lines(plan: &ExternalHookImportPlanV1) -> Vec { let mut lines = vec![format!( "Hook import {:?}: {} handler(s) from {}", plan.disposition, @@ -336,6 +338,11 @@ pub(crate) fn render_plan(plan: &ExternalHookImportPlanV1) -> String { 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) @@ -344,6 +351,15 @@ pub(crate) fn render_plan(plan: &ExternalHookImportPlanV1) -> 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}"), @@ -367,7 +383,7 @@ mod tests { ExternalHookCatalogSnapshotV1, ExternalHookSource, ExternalHookSourceKind, }; use bitfun_product_domains::external_hook_import::{ - ImportedHookSourceSnapshotV1, ImportedHookSourceStateV1, + ExternalHookImportDispositionV1, ImportedHookSourceSnapshotV1, ImportedHookSourceStateV1, }; use bitfun_product_domains::external_sources::{ EcosystemId, ExternalSourceHealth, ExternalSourceScope, @@ -423,4 +439,34 @@ mod tests { "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/modes/chat/external_hooks.rs b/src/apps/cli/src/modes/chat/external_hooks.rs index b438ff67e6..3ee530c48c 100644 --- a/src/apps/cli/src/modes/chat/external_hooks.rs +++ b/src/apps/cli/src/modes/chat/external_hooks.rs @@ -827,7 +827,7 @@ impl ChatMode { self.hook_management_snapshot = Some(snapshot); } Ok(HookManagementResult::Plan(plan)) => { - chat_state.add_system_message(crate::hook_import::render_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(), diff --git a/src/apps/cli/src/modes/chat/native_hooks.rs b/src/apps/cli/src/modes/chat/native_hooks.rs index c5fd8febef..9b94c67d5f 100644 --- a/src/apps/cli/src/modes/chat/native_hooks.rs +++ b/src/apps/cli/src/modes/chat/native_hooks.rs @@ -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 { diff --git a/src/apps/cli/src/modes/chat/tests.rs b/src/apps/cli/src/modes/chat/tests.rs index 9459b1db56..d6d73a178d 100644 --- a/src/apps/cli/src/modes/chat/tests.rs +++ b/src/apps/cli/src/modes/chat/tests.rs @@ -854,7 +854,7 @@ 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")); diff --git a/src/web-ui/src/infrastructure/config/components/HooksConfig.test.tsx b/src/web-ui/src/infrastructure/config/components/HooksConfig.test.tsx index c86b560aaa..ac195dd76a 100644 --- a/src/web-ui/src/infrastructure/config/components/HooksConfig.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/HooksConfig.test.tsx @@ -194,6 +194,7 @@ describe('HooksConfig imported Hook management', () => { 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')!; @@ -286,4 +287,78 @@ describe('HooksConfig imported Hook management', () => { 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 e553b941ff..33e55d8223 100644 --- a/src/web-ui/src/infrastructure/config/components/HooksConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/HooksConfig.tsx @@ -197,7 +197,17 @@ const HooksConfig: React.FC = () => { setImportSnapshot(result.outcome.snapshot); setReviewPlan(null); setPlanNotice(null); - notifySuccess(t('imports.applied')); + 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; @@ -206,7 +216,7 @@ const HooksConfig: React.FC = () => { } finally { if (mountedRef.current) setBusyKey(null); } - }, [notifyError, notifySuccess, reviewPlan, t, workspacePath]); + }, [config.enabled, notifyError, notifySuccess, reviewPlan, t, workspacePath]); const mutateImport = useCallback(async ( action: ExternalHookImportMutation, @@ -277,6 +287,10 @@ const HooksConfig: React.FC = () => { 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 ( @@ -517,7 +531,6 @@ const HooksConfig: React.FC = () => { {t('imports.skipped', { reason: skipped.reasonCode, count: skipped.count })}

))} -

{t('imports.fingerprint', { fingerprint: reviewPlan.planFingerprint })}

) : null} 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 ce8639e27a..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,12 +20,12 @@ "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": { @@ -58,12 +58,14 @@ "external": "External dependency" }, "skipped": "Skipped {{count}} item(s): {{reason}}", - "fingerprint": "Reviewed plan: {{fingerprint}}", "cancel": "Cancel", - "confirm": "Import and enable", + "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.", 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 5a0ceec5eb..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,12 +20,12 @@ "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": { @@ -58,12 +58,14 @@ "external": "外部依赖" }, "skipped": "已跳过 {{count}} 项:{{reason}}", - "fingerprint": "已审阅计划:{{fingerprint}}", "cancel": "取消", - "confirm": "导入并启用", + "confirm": "导入已审阅的 Hooks", + "confirmUpdate": "应用已审阅的更新", "stale": "来源已变化。本次没有写入,请重新审阅更新后的计划。", "applyFailed": "无法应用已审阅的 Hook 导入。", "applied": "导入的 Hooks 将从下一个匹配事件开始启用。", + "appliedDisabled": "已应用审阅后的更新;这些导入 Hooks 仍保持停用。", + "appliedMasterDisabled": "Hooks 已导入,但只有开启“启用 Hooks”后才会运行。", "updated": "已更新导入 Hook 的状态。", "stateChanged": "导入 Hook 的状态已变化。请检查刷新后的状态,再重新操作。", "updateFailed": "无法更新导入 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 eb1e6e4db1..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,12 +20,12 @@ "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": { @@ -58,12 +58,14 @@ "external": "外部相依項目" }, "skipped": "已略過 {{count}} 項:{{reason}}", - "fingerprint": "已檢閱計畫:{{fingerprint}}", "cancel": "取消", - "confirm": "匯入並啟用", + "confirm": "匯入已檢閱的 Hooks", + "confirmUpdate": "套用已檢閱的更新", "stale": "來源已變更。本次未寫入,請重新檢閱更新後的計畫。", "applyFailed": "無法套用已檢閱的 Hook 匯入。", "applied": "匯入的 Hooks 將從下一個符合的事件開始啟用。", + "appliedDisabled": "已套用檢閱後的更新;這些匯入 Hooks 仍保持停用。", + "appliedMasterDisabled": "Hooks 已匯入,但只有開啟「啟用 Hooks」後才會執行。", "updated": "已更新匯入 Hook 的狀態。", "stateChanged": "匯入 Hook 的狀態已變更。請檢查重新整理後的狀態,再重新操作。", "updateFailed": "無法更新匯入 Hook 的狀態。",