From ed103c47e27f12d7d962470712efb8ec58c3897d Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 01:05:46 +0200 Subject: [PATCH 01/13] fix(hooks): trust teamai's Codex hooks and keep project team hooks in the main checkout (#955) --- docs/designs/data-directory-layout.md | 2 + docs/usage-guide.md | 13 +- docs/usage-guide.zh-CN.md | 13 +- skill-data/core/references/troubleshooting.md | 21 +- skill-data/setup/SKILL.md | 2 +- skill-data/setup/references/setup-admin.md | 2 +- src/__tests__/codex-trust.test.ts | 408 ++++++++++++++++ src/__tests__/doctor.test.ts | 69 ++- .../helpers/clear-agent-session-env.ts | 18 + src/__tests__/helpers/fake-codex.ts | 117 +++++ src/__tests__/hooks-cmd.test.ts | 41 +- src/__tests__/hooks-reconcile-scope.test.ts | 212 ++++++-- src/__tests__/init.test.ts | 2 + src/__tests__/pull-codex-trust.test.ts | 154 ++++++ src/__tests__/uninstall.test.ts | 26 + src/bootstrap.ts | 5 +- src/codex-trust.ts | 258 ++++++++++ src/doctor.ts | 57 ++- src/hooks-cmd.ts | 20 +- src/hooks.ts | 452 +++++++++++++++--- src/init.ts | 3 +- src/pull.ts | 27 ++ src/types.ts | 11 + src/uninstall.ts | 32 +- src/utils/lookpath.ts | 12 +- 25 files changed, 1781 insertions(+), 196 deletions(-) create mode 100644 src/__tests__/codex-trust.test.ts create mode 100644 src/__tests__/helpers/fake-codex.ts create mode 100644 src/__tests__/pull-codex-trust.test.ts create mode 100644 src/codex-trust.ts diff --git a/docs/designs/data-directory-layout.md b/docs/designs/data-directory-layout.md index cf611d149..c966e40d9 100644 --- a/docs/designs/data-directory-layout.md +++ b/docs/designs/data-directory-layout.md @@ -495,6 +495,8 @@ every checkout, so that is where they live now: ├── learnings-wt/ getWorktreeDir → / ├── reports-wt/ (the side-branch locks sit beside them) ├── pending-learnings/ pendingLearningsDir → /pending-learnings +├── managed-main-checkout-hooks.json team hooks teamai wrote ungated into the main checkout's Claude Code / Codex +│ settings, shared by every checkout (#955; the built-in hooks stay in HOME) └── workspaces// ├── managed-mcp.json managedMcpManifestPath, one per checkout; Copilot placement is true for bare, false for keyed, absent when unproven ├── managed-mcp-files.json resolvedMcpFilesPath: project MCP configs teamai may have written a resolved ${VAR} to, and whether diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 871991b75..05313f221 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -660,7 +660,7 @@ A manual `teamai pull` ends by running the `teamai doctor` checks and printing e **Pull keeps a skill, rule or agent you changed.** For each checkout, pull records what it wrote at each skill, rule and agent path. On a full sync, a copy that no longer matches that record is kept, and pull names it, while the copies of other tools still update. A skill counts as one copy: a change to any of its team files keeps the whole skill, and files only you added do not count. If the team version has not changed, pull prints ``Kept : you changed it since teamai delivered it. Share it with `teamai push`, or delete it and run `teamai pull --force` to get the team version back.`` If it has, whether the team changed it or your [local model alias override](#local-override) did, pull warns and asks you to merge that change into your copy before you push it, and `teamai push` warns about that copy too, since the SessionStart pull runs silently. `--force` keeps these copies too, and `--dry-run` prints `Would keep ` for each. When the team removes an item, a copy you changed stays, and pull names it. There is no record before your first full pull with this version, so that pull overwrites as earlier versions did, and your changes are protected from then on. The same goes for a new worktree's first pull, and for a copy teamai never delivered to that path. `teamai remove` and installs from the local agent still rewrite the team rules without this check. An older CLI that saves state drops the record. -> Project scope is isolated by default. When the current working directory contains a project-scope `.teamai/config.yaml`, `pull` processes that project and skips user scope unless the local config has `inheritUserScope: true`; in that case it first refreshes the safe user-resource channel. Without a project config in the current directory, `pull` processes user scope. User `env`, MCP definitions, sources, reporting, and writes remain isolated in project mode. Hooks are the one exception: a project scope's hooks are injected into your **HOME** tool settings (`~/.claude/settings.json`, …), not ``, because the built-in hooks gate on the `cwd` handed to `hook-dispatch` and `~/.claude` always exists so the "installed tool" gate passes (see the Hooks section). In a directory with no teamai config (no project config and no user scope), the team hooks do nothing: no reminders, and no session or skill usage is recorded; only machine-level work runs (the CLI update check, the session-start pull, the local agent, and package hints a pull stashed). For the team hooks and skill usage, a project config that exists but cannot be read counts as none, never as the user scope or as a lower-priority project config (such as a legacy `.teamai/config.yaml`) behind it. `pull` follows the same rule: it syncs no scope there, prints ``Nothing was synced: : . Fix the file, or move it aside and run `teamai init` to write a new one.`` and exits 1 (with `--silent`, it prints nothing and still exits 1); a session start there runs no pull, seeds no agent directory and stashes no package hint. A hook whose `cwd` was deleted (a session that outlives its worktree) keeps the scope its session last recorded, so the session's last events and skill uses stay with the project, and its share reminder follows the project's settings, instead of the user scope's. This needs the session's earlier events in the local event log, which compaction trims to active sessions, and does not cover Copilot, whose events record no directory. Self single-repo mode keeps its hooks in the business repo so they travel on clone. +> Project scope is isolated by default. When the current working directory contains a project-scope `.teamai/config.yaml`, `pull` processes that project and skips user scope unless the local config has `inheritUserScope: true`; in that case it first refreshes the safe user-resource channel. Without a project config in the current directory, `pull` processes user scope. User `env`, MCP definitions, sources, reporting, and writes remain isolated in project mode. Hooks are the one exception: a project scope's built-in hooks are injected into your **HOME** tool settings (`~/.claude/settings.json`, …), not ``, because they gate on the `cwd` handed to `hook-dispatch` and `~/.claude` always exists so the "installed tool" gate passes (see the Hooks section). The team's own hooks (`hooks/hooks.yaml`) for Claude Code and Codex go to the main checkout instead, ungated (`
/.claude/settings.local.json`, `
/.codex/hooks.json`), so every worktree of the project shares one copy; other tools keep them in HOME, run only when the `cwd` is inside the project. In a directory with no teamai config (no project config and no user scope), the team hooks do nothing: no reminders, and no session or skill usage is recorded; only machine-level work runs (the CLI update check, the session-start pull, the local agent, and package hints a pull stashed). For the team hooks and skill usage, a project config that exists but cannot be read counts as none, never as the user scope or as a lower-priority project config (such as a legacy `.teamai/config.yaml`) behind it. `pull` follows the same rule: it syncs no scope there, prints ``Nothing was synced: : . Fix the file, or move it aside and run `teamai init` to write a new one.`` and exits 1 (with `--silent`, it prints nothing and still exits 1); a session start there runs no pull, seeds no agent directory and stashes no package hint. A hook whose `cwd` was deleted (a session that outlives its worktree) keeps the scope its session last recorded, so the session's last events and skill uses stay with the project, and its share reminder follows the project's settings, instead of the user scope's. This needs the session's earlier events in the local event log, which compaction trims to active sessions, and does not cover Copilot, whose events record no directory. Self single-repo mode keeps its hooks in the business repo so they travel on clone. With role-based skills enabled, `pull`'s skill sync source becomes the contents of `skills//`, expanded according to `primaryRole + additionalRoles` and flattened into each local AI tool's skills directory. `rules//` and `claudemd//` follow the `knowledge` namespaces, and a `docs//` follows the `docs` namespaces once one is declared (see [Docs](#docs)); `agents//` follows the role's `agents` namespaces (see [Agents Resource Type](#agents-resource-type)). `learnings/` at the root is shared with everyone, while `learnings//` subdirectories sync only for the directory's active projects (see [Multi-project](#multi-project-project-as-a-dimension-orthogonal-to-role)). @@ -966,7 +966,7 @@ The culture, shared-instructions and recall blocks follow the same split. In use > A `toolPaths` in the team `teamai.yaml` replaces the built-in defaults whole. A team that sets it should give each Codex-family entry `userScope.claudemd: .codex/AGENTS.md` (`.codex-internal/…`, `.tcodex/…`) for the user-scope rules and blocks, and drop its `rules` path, since Codex never reads that directory. A top-level `claudemd` would put the blocks back in the project `AGENTS.md`, so leave it out. In a project the hook needs only the entry's `settings` path, where it is installed. -> Upgrading from a release that copied rules to `.codex/rules/`: the next `pull` removes the `.md` copies teamai delivered there, including `teamai-recall.md`. Cleanup follows the recorded `toolRoots` location and checks both a publisher's bare local filename and its namespaced copy. A copy you edited is kept and named in a warning, and the `*.rules` files are never touched. A copy of a rule the team has since removed is deleted only if it matches its recorded delivery hash; without that record, it is kept and named too. The same pull adds `additionalContextLimit: 0` and a `SubagentStart` entry to the teamai hooks in `hooks.json`, so the public Codex asks you once to approve the changed hooks. +> Upgrading from a release that copied rules to `.codex/rules/`: the next `pull` removes the `.md` copies teamai delivered there, including `teamai-recall.md`. Cleanup follows the recorded `toolRoots` location and checks both a publisher's bare local filename and its namespaced copy. A copy you edited is kept and named in a warning, and the `*.rules` files are never touched. A copy of a rule the team has since removed is deleted only if it matches its recorded delivery hash; without that record, it is kept and named too. The same pull adds `additionalContextLimit: 0` and a `SubagentStart` entry to the teamai hooks in `hooks.json`, which teamai then trusts again in the public Codex (see [Hooks](#hooks)). ### Env, hooks and MCP servers by namespace @@ -1924,7 +1924,7 @@ On Windows, the built-in hook dispatch commands that shell out through bash (e.g Cursor also loads `~/.claude/settings.json`, and Copilot CLI loads a trusted project's `.claude/settings.json` (self mode writes hooks there; Copilot does not load `~/.claude/settings.json`). `hook-dispatch --tool claude` exits only when that other host's own teamai hooks are on disk: `~/.cursor/hooks.json` or `$CURSOR_PROJECT_DIR/.cursor/hooks.json` contains `--tool cursor`, or `$COPILOT_PROJECT_DIR/.github/hooks/teamai.json` contains `--tool copilot`. Team hook commands written for `claude` use the same check. A setup with only Claude keeps running inside Cursor, because there is no second copy. `COPILOT_CLI` is not a signal: Copilot sets it on every subprocess, including a Claude session started from its shell. Claude Code sets neither `CURSOR_VERSION` nor `COPILOT_PROJECT_DIR`. Run `teamai pull` or `teamai hooks inject` again so an already installed team hook picks up the guard. -> **Codex trust gate** — Codex (the OpenAI / ChatGPT Codex app, tool id `codex`) gates non-managed hooks behind an explicit user trust step. After teamai writes `~/.codex/hooks.json`, Codex may skip a newly added or changed hook until you review/trust it in `/hooks` or Settings → Hooks. `teamai hooks inject` and `teamai doctor` print a reminder when Codex hooks are installed; teamai never edits Codex's `[hooks.state]` to auto-trust — trusting is left to you. +> **Codex hook trust** — Codex (the OpenAI / ChatGPT Codex app, tool id `codex`) runs a non-managed hook only once it is trusted, and skips an untrusted or changed one without a word; it reads a project's `.codex/` only when the project is trusted. So after every write of a Codex hooks file (`init`, every `pull` including the session-start one, `teamai hooks inject`) teamai trusts exactly the hooks it wrote, through `codex app-server` — the same call Codex's `/hooks` trust prompt makes. Your own hooks in the same file are left alone. In a project, teamai also trusts the main checkout when Codex has to read teamai's hooks or MCP servers from its `.codex/`; a project you marked untrusted in Codex stays so, and teamai says so. Trust written by a session-start pull applies from the next Codex session: the running one already loaded its hooks. A linked worktree reads the main checkout's `.codex/hooks.json` only once it has a `.codex/` directory, which the Codex session-start hook creates, so a new worktree gets the team hooks from its second Codex session; the built-in hooks live in `~/.codex/hooks.json` and run from the first. To trust them yourself, set `codexTrustEnabled: false` in `config.yaml`. Without `codex` on PATH, or when the app-server fails, teamai prints a reminder to trust them in `/hooks` or Settings → Hooks instead. `teamai doctor` asks Codex which teamai hooks it will not run and names them. ### Team Hooks Declaration @@ -2266,7 +2266,7 @@ Three tools do not read a rules directory, so a per-file check cannot speak for } ``` -`scope` is `null` before initialization. `packages` is present only when the team repo declares packages, and carries the rendered report lines. `notes` appears only when there is an advisory: the namespace notes described above (an item that replaces a root one, or without roles or projects a name defined more than once) and the Codex trust-gate reminder. +`scope` is `null` before initialization. `packages` is present only when the team repo declares packages, and carries the rendered report lines. `notes` appears only when there is an advisory: the namespace notes described above (an item that replaces a root one, or without roles or projects a name defined more than once) and the Codex hook trust reminder when Codex cannot be asked (no `codex` on PATH, or its app-server failed). Auto-update runs in the Stop hook and is controlled by two tiers: @@ -2596,6 +2596,7 @@ projectRoot: /path/to/project # project scope only inheritUserScope: true # optional; project scope only, defaults to false coAuthorEnabled: true # optional; per-machine co-author override contributeHintEnabled: false # optional; per-machine override of sharing.contributeHint.enabled +codexTrustEnabled: false # optional; per-machine, stops teamai trusting its Codex hooks and project (see Hooks) toolRoots: # optional; per-machine tool roots (see below) claude: ~/.claude-work codex: ~/.codex-alt @@ -2603,7 +2604,7 @@ toolRoots: # optional; per-machine tool roots (see below) #### Relocated tool roots (`toolRoots`) -A tool that can be told to keep its configuration somewhere else — Claude Code through `CLAUDE_CONFIG_DIR`, Codex through `CODEX_HOME` — reads nothing that teamai writes to the team-wide default. `toolRoots` names the directory that tool actually uses, keyed by the same tool id as `toolPaths`, and every path teamai resolves for it (skills, rules, agents, `CLAUDE.md`, settings and hooks, the user-scope MCP config, and Codex's co-author setting in `config.toml`) moves there with it. Other tools are untouched, and so are project-scope paths: those hang off the project root, where a per-machine root has nothing to say. Hooks are the exception that makes this worth recording — they are injected into your home directory even in project scope, so they follow `toolRoots` in both. +A tool that can be told to keep its configuration somewhere else — Claude Code through `CLAUDE_CONFIG_DIR`, Codex through `CODEX_HOME` — reads nothing that teamai writes to the team-wide default. `toolRoots` names the directory that tool actually uses, keyed by the same tool id as `toolPaths`, and every path teamai resolves for it (skills, rules, agents, `CLAUDE.md`, settings and hooks, the user-scope MCP config, and Codex's co-author setting in `config.toml`) moves there with it. Other tools are untouched, and so are project-scope paths: those hang off the project root, where a per-machine root has nothing to say. Hooks are the exception that makes this worth recording — the built-in hooks are injected into your home directory even in project scope, so they follow `toolRoots` in both, and teamai trusts Codex hooks in the `config.toml` under `toolRoots.codex`. `teamai init` fills it in for you: whenever `CLAUDE_CONFIG_DIR` or `CODEX_HOME` is set, init records the directory it points at (`toolRoots.claude`, `toolRoots.codex`) and prints it. That includes `CLAUDE_CONFIG_DIR=~/.claude`, which is not the same as leaving the variable unset — Claude Code reads `.claude.json` from inside the configured directory, so teamai writes the MCP config to `~/.claude/.claude.json` rather than `~/.claude.json`. `init` is also the only command that reads these variables, because they live in one shell profile while teamai also runs from session hooks and other terminals; resolving it per run would make the sync target depend on who started the process. A re-init keeps a root that was recorded earlier, so running `init` from a shell without the variable does not send the sync back to the default. When a re-init does move the root, the hooks teamai injected into the previous root's settings file (`settings.json`, Codex's `hooks.json`) are removed so that the tool stops syncing into the new one; the skills, rules and instruction files written there are left in place and named in the output. A project-scope `init` that has no record of its own and no variable to read starts from the user-scope record, since the root is a fact about the machine and project hooks land in your home directory. To end a relocation, run `init` once with the variable set but blank (`CLAUDE_CONFIG_DIR= teamai init …`, `CODEX_HOME= teamai init …`): the record is cleared and the old root released the same way. Along with the hooks, the old root loses the teamai-managed MCP servers and, for Claude Code, any gateway credentials the local agent delivered there; they are active configuration, unlike the skills and rules. @@ -2826,7 +2827,7 @@ teamai pull **Q: Can user scope and project scope coexist?** -Yes, but project scope remains isolated by default. When the current working directory contains a project-scope config, it is active and user scope is skipped. Initialize user scope first, then initialize the project with `--inherit-user-scope` (or set `inheritUserScope: true` in the project's local config) to compose safe resources and Recall results. Executable and control-plane configuration (`env`, MCP) remains project-only; hooks are the exception — a non-self project scope injects them into HOME so `hook-dispatch` can gate on `cwd` (see the Hooks section). +Yes, but project scope remains isolated by default. When the current working directory contains a project-scope config, it is active and user scope is skipped. Initialize user scope first, then initialize the project with `--inherit-user-scope` (or set `inheritUserScope: true` in the project's local config) to compose safe resources and Recall results. Executable and control-plane configuration (`env`, MCP) remains project-only; hooks are the exception — a non-self project scope injects the built-in hooks into HOME so `hook-dispatch` can gate on `cwd` (see the Hooks section). **Q: `teamai init` says it's already initialized?** diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 2d3f47adf..9c3fda64b 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -586,7 +586,7 @@ teamai pull --dry-run # 试运行,不实际修改 **pull 会保留你修改过的 skill、rule 和 agent。** pull 按检出记录它在每个 skill、rule、agent 路径写入的内容。完整同步时,与记录不一致的副本会被保留并由 pull 指出,其他工具的副本照常更新。一个 skill 算作一份副本:它的任一团队文件被改动,整个 skill 都会保留;只有你自己添加的文件不计入。团队版本没有变化时,pull 输出 ``Kept : you changed it since teamai delivered it. Share it with `teamai push`, or delete it and run `teamai pull --force` to get the team version back.``;团队版本也变了时(无论是团队改的,还是你的[本地模型别名覆盖](#本地覆盖)导致的),pull 给出警告,请你先把这项改动合并进自己的副本,再 push;由于 SessionStart 时的 pull 不输出信息,`teamai push` 也会对该副本给出警告。`--force` 同样保留这些副本,`--dry-run` 会逐个输出 `Would keep `。团队删除某项资源时,你修改过的副本也会保留,并由 pull 指出。升级后第一次完整 pull 之前还没有记录,因此那次 pull 仍像旧版本一样覆盖,此后你的修改才受保护。新 worktree 的第一次 pull、以及 teamai 从未写入过该路径的副本,同样如此。`teamai remove` 和本地 agent 的安装仍会不经这项检查重写团队 rule。旧版 CLI 保存 state 时会丢弃这份记录。 -> Project scope 默认与 user scope 隔离。当前工作目录包含 project scope 的 `.teamai/config.yaml` 时,`pull` 会处理该项目并跳过 user scope;仅当本地配置包含 `inheritUserScope: true` 时,才会先刷新安全的 user 资源通道。当前目录没有 project 配置时,`pull` 处理 user scope。project 模式下,user 的 `env`、MCP 定义、sources、reporting 和写入行为仍保持隔离。hooks 是唯一例外:project scope 的 hooks 会注入到你的 **HOME** 工具设置(`~/.claude/settings.json` 等),而非 ``——因为内置 hooks 依据传给 `hook-dispatch` 的 `cwd` 门控,且 `~/.claude` 恒存在、能通过「已安装工具」门槛(详见 Hooks 章节)。在没有 teamai 配置的目录中(既没有 project 配置也没有 user scope),团队 hooks 不做任何事:不显示提醒,也不记录会话或 skill 使用;只运行机器级别的工作(CLI 更新检查、SessionStart 时的 pull、本地 agent,以及 pull 暂存的包提示)。对团队 hooks 和 skill 使用记录而言,存在但无法读取的 project 配置视为没有配置,而不会退回 user scope,也不会退回其后优先级更低的 project 配置(如旧的 `.teamai/config.yaml`)。`pull` 遵循同一规则:此时不同步任何 scope,输出 ``Nothing was synced: : . Fix the file, or move it aside and run `teamai init` to write a new one.`` 并以 exit 1 退出(加 `--silent` 时不输出,但仍以 exit 1 退出);会话启动时不运行 pull,也不创建 agent 目录、不暂存包提示。`cwd` 已被删除的 hook(会话比它的 worktree 活得更久)沿用该会话最后记录的 scope,因此会话最后的事件和 skill 使用仍归属项目,分享提醒也遵循项目的设置,而不是 user scope 的。这需要本地事件日志中仍保留该会话之前的事件(压缩只保留活跃会话),且不适用于 Copilot,因为它的事件不记录目录。self 单仓模式则把 hooks 保留在业务仓库里,随 clone 传播。 +> Project scope 默认与 user scope 隔离。当前工作目录包含 project scope 的 `.teamai/config.yaml` 时,`pull` 会处理该项目并跳过 user scope;仅当本地配置包含 `inheritUserScope: true` 时,才会先刷新安全的 user 资源通道。当前目录没有 project 配置时,`pull` 处理 user scope。project 模式下,user 的 `env`、MCP 定义、sources、reporting 和写入行为仍保持隔离。hooks 是唯一例外:project scope 的内置 hooks 会注入到你的 **HOME** 工具设置(`~/.claude/settings.json` 等),而非 ``——因为它们依据传给 `hook-dispatch` 的 `cwd` 门控,且 `~/.claude` 恒存在、能通过「已安装工具」门槛(详见 Hooks 章节)。团队自己的 hooks(`hooks/hooks.yaml`)对 Claude Code 和 Codex 则写入主 checkout,不加门控(`<主 checkout>/.claude/settings.local.json`、`<主 checkout>/.codex/hooks.json`),项目的所有 worktree 共用一份;其他工具仍写在 HOME,仅在 `cwd` 位于该项目内时运行。在没有 teamai 配置的目录中(既没有 project 配置也没有 user scope),团队 hooks 不做任何事:不显示提醒,也不记录会话或 skill 使用;只运行机器级别的工作(CLI 更新检查、SessionStart 时的 pull、本地 agent,以及 pull 暂存的包提示)。对团队 hooks 和 skill 使用记录而言,存在但无法读取的 project 配置视为没有配置,而不会退回 user scope,也不会退回其后优先级更低的 project 配置(如旧的 `.teamai/config.yaml`)。`pull` 遵循同一规则:此时不同步任何 scope,输出 ``Nothing was synced: : . Fix the file, or move it aside and run `teamai init` to write a new one.`` 并以 exit 1 退出(加 `--silent` 时不输出,但仍以 exit 1 退出);会话启动时不运行 pull,也不创建 agent 目录、不暂存包提示。`cwd` 已被删除的 hook(会话比它的 worktree 活得更久)沿用该会话最后记录的 scope,因此会话最后的事件和 skill 使用仍归属项目,分享提醒也遵循项目的设置,而不是 user scope 的。这需要本地事件日志中仍保留该会话之前的事件(压缩只保留活跃会话),且不适用于 Copilot,因为它的事件不记录目录。self 单仓模式则把 hooks 保留在业务仓库里,随 clone 传播。 启用角色化 skills 后,`pull` 的 skills 同步来源会变成 `skills//` 中的内容,按 `primaryRole + additionalRoles` 展开对应的 namespace,拍平安装到本地各 AI 工具 skills 目录。`rules//` 和 `claudemd//` 按 `knowledge` namespace 同步,`docs//` 在被声明后按 `docs` namespace 同步(见 [Docs(文档)](#docs文档));`agents//` 按角色的 `agents` namespace 同步(见 [Agents 资源类型](#agents-资源类型))。`learnings/` 根目录对所有人共享,而 `learnings//` 子目录只对本目录激活的项目同步(见 [多项目](#多项目project-作为与-role-正交的维度))。 @@ -887,7 +887,7 @@ culture、共享指令和 recall 区块采用同样的划分。user scope 下它 > 团队 `teamai.yaml` 中的 `toolPaths` 会整体替换内置默认值。设置了它的团队应为每个 Codex 系条目加上 `userScope.claudemd: .codex/AGENTS.md`(`.codex-internal/…`、`.tcodex/…`),用于 user scope 的 rule 和区块,并去掉其 `rules` 路径,因为 Codex 从不读取该目录。顶层的 `claudemd` 会把区块重新写进项目 `AGENTS.md`,所以不要设置。在项目中,hook 只需要该条目的 `settings` 路径,它安装在那里。 -> 从把 rule 复制到 `.codex/rules/` 的旧版本升级后,下一次 `pull` 会删除 teamai 投递到那里的 `.md` 副本,包括 `teamai-recall.md`。清理使用记录的 `toolRoots` 位置,同时检查发布者本地的无命名空间文件名及命名空间副本。你改过的副本会保留,并在警告中点名;`*.rules` 文件从不改动。团队此后已删除的 rule,其副本只有与记录的投递哈希一致时才会删除;没有该记录时也会保留并点名。同一次 pull 会为 `hooks.json` 中的 teamai hook 加上 `additionalContextLimit: 0` 和一个 `SubagentStart` 条目,因此公开版 Codex 会请你批准一次改动后的 hook。 +> 从把 rule 复制到 `.codex/rules/` 的旧版本升级后,下一次 `pull` 会删除 teamai 投递到那里的 `.md` 副本,包括 `teamai-recall.md`。清理使用记录的 `toolRoots` 位置,同时检查发布者本地的无命名空间文件名及命名空间副本。你改过的副本会保留,并在警告中点名;`*.rules` 文件从不改动。团队此后已删除的 rule,其副本只有与记录的投递哈希一致时才会删除;没有该记录时也会保留并点名。同一次 pull 会为 `hooks.json` 中的 teamai hook 加上 `additionalContextLimit: 0` 和一个 `SubagentStart` 条目,随后 teamai 会在公开版 Codex 中重新信任这些 hook(见 Hooks 章节)。 ### Env、hooks 与 MCP server 按 namespace 划分 @@ -1789,7 +1789,7 @@ inject 和 remove 只会操作你实际已安装的工具(即 `~/./` 根 Cursor 也会加载 `~/.claude/settings.json`。Copilot CLI 会加载受信任项目里的 `.claude/settings.json`(self mode 把 hook 写在项目里;Copilot 不加载 `~/.claude/settings.json`)。只有另一边的 teamai hook 已经在磁盘上时,`hook-dispatch --tool claude` 才会退出:`~/.cursor/hooks.json` 或 `$CURSOR_PROJECT_DIR/.cursor/hooks.json` 含有 `--tool cursor`,或 `$COPILOT_PROJECT_DIR/.github/hooks/teamai.json` 含有 `--tool copilot`。写给 `claude` 的团队 hook 命令用同一判断。只启用了 Claude 时,Cursor 里这份 hook 照常运行,因为没有第二份可以接替。`COPILOT_CLI` 不能当信号:Copilot 会给每个子进程设置它,包括从它的 shell 里启动的 Claude。Claude Code 不会设置 `CURSOR_VERSION` 或 `COPILOT_PROJECT_DIR`。已经装好的团队 hook 需要再跑一次 `teamai pull` 或 `teamai hooks inject`,才会带上这个判断。 -> **Codex 信任门槛** — Codex(OpenAI / ChatGPT Codex 应用,工具 id 为 `codex`)对非托管 hooks 设有显式的用户信任机制。teamai 写入 `~/.codex/hooks.json` 后,对于新增或变更的 hook,Codex 可能会跳过执行,直到你在 `/hooks` 或 Settings → Hooks 中 review/trust。当检测到 Codex hooks 已安装时,`teamai hooks inject` 与 `teamai doctor` 会输出提示;teamai 从不修改 Codex 的 `[hooks.state]` 来自动信任 —— 信任操作交由你手动完成。 +> **Codex hook 信任** — Codex(OpenAI / ChatGPT Codex 应用,工具 id 为 `codex`)只运行已信任的非托管 hook,未信任或已变更的 hook 会被静默跳过;且只有项目被信任时才读取其 `.codex/`。因此每次写入 Codex hooks 文件后(`init`、每次 `pull`(含 SessionStart 触发的 pull)、`teamai hooks inject`),teamai 都会通过 `codex app-server` 信任它写入的那些 hook——与 Codex `/hooks` 信任提示调用的是同一接口。同一文件里你自己的 hook 不受影响。在项目中,当 Codex 需要从主 checkout 的 `.codex/` 读取 teamai 的 hooks 或 MCP servers 时,teamai 也会信任该主 checkout;你在 Codex 中标记为不信任的项目保持不变,teamai 会提示。SessionStart 触发的 pull 写入的信任从下一个 Codex 会话起生效:当前会话已加载了它的 hooks。linked worktree 只有在存在 `.codex/` 目录时才读取主 checkout 的 `.codex/hooks.json`,该目录由 Codex 的 SessionStart hook 创建,因此新 worktree 从第二个 Codex 会话起获得团队 hooks;内置 hooks 位于 `~/.codex/hooks.json`,从第一个会话起就运行。若要自行信任,在 `config.yaml` 中设置 `codexTrustEnabled: false`。PATH 中没有 `codex`、或 app-server 失败时,teamai 改为输出提示,请你在 `/hooks` 或 Settings → Hooks 中信任。`teamai doctor` 会向 Codex 查询哪些 teamai hooks 不会运行并逐一列出。 ### 团队 Hooks 声明 @@ -2129,7 +2129,7 @@ teamai remove rules --force # 跳过确认,用于脚本和 CI } ``` -尚未初始化时 `scope` 为 `null`。仅当团队仓库声明了 packages 时才会出现 `packages` 字段,内容是已渲染的报告行;`notes` 只在有额外提示时出现:上文所述的 namespace 提示(替换了根目录条目的条目,或未配置角色或项目时重复定义的名字),以及 Codex 信任门槛提醒。 +尚未初始化时 `scope` 为 `null`。仅当团队仓库声明了 packages 时才会出现 `packages` 字段,内容是已渲染的报告行;`notes` 只在有额外提示时出现:上文所述的 namespace 提示(替换了根目录条目的条目,或未配置角色或项目时重复定义的名字),以及无法查询 Codex 时(PATH 中没有 `codex`,或其 app-server 失败)的 Codex hook 信任提醒。 自动更新在 Stop hook 中执行,可通过两层控制: @@ -2427,6 +2427,7 @@ projectRoot: /path/to/project # 仅 project scope inheritUserScope: true # 可选,仅 project scope,默认 false coAuthorEnabled: true # 可选,每机器的 co-author 覆盖 contributeHintEnabled: false # 可选,每机器覆盖 sharing.contributeHint.enabled +codexTrustEnabled: false # 可选,每机器,停止 teamai 信任它写入的 Codex hooks 与项目(见 Hooks) toolRoots: # 可选,每机器的工具根目录(见下) claude: ~/.claude-work codex: ~/.codex-alt @@ -2434,7 +2435,7 @@ toolRoots: # 可选,每机器的工具根目录(见下 #### 迁移后的工具根目录(`toolRoots`) -有的工具可以把自己的配置放到别处——Claude Code 通过 `CLAUDE_CONFIG_DIR`、Codex 通过 `CODEX_HOME` 这样做——此时 teamai 按团队默认位置写入的内容它一概读不到。`toolRoots` 用与 `toolPaths` 相同的工具 id 指明该工具实际使用的目录,teamai 为它解析的所有路径(skills、rules、agents、`CLAUDE.md`、settings 与 hook、用户级 MCP 配置,以及 Codex 写在 `config.toml` 里的 co-author 设置)都会一并迁过去。其他工具不受影响,project scope 的路径也不受影响:那些路径挂在项目根目录下,每机器的根目录对它们没有意义。hook 是个例外,也正是值得记录 `toolRoots` 的原因——即使在 project scope,hook 也注入到 home 目录,因此两种 scope 下都跟随 `toolRoots`。 +有的工具可以把自己的配置放到别处——Claude Code 通过 `CLAUDE_CONFIG_DIR`、Codex 通过 `CODEX_HOME` 这样做——此时 teamai 按团队默认位置写入的内容它一概读不到。`toolRoots` 用与 `toolPaths` 相同的工具 id 指明该工具实际使用的目录,teamai 为它解析的所有路径(skills、rules、agents、`CLAUDE.md`、settings 与 hook、用户级 MCP 配置,以及 Codex 写在 `config.toml` 里的 co-author 设置)都会一并迁过去。其他工具不受影响,project scope 的路径也不受影响:那些路径挂在项目根目录下,每机器的根目录对它们没有意义。hook 是个例外,也正是值得记录 `toolRoots` 的原因——即使在 project scope,内置 hook 也注入到 home 目录,因此两种 scope 下都跟随 `toolRoots`,teamai 也在 `toolRoots.codex` 下的 `config.toml` 中信任 Codex hooks。 `teamai init` 会自动写入:只要设置了 `CLAUDE_CONFIG_DIR` 或 `CODEX_HOME`,init 就记录它指向的目录(`toolRoots.claude`、`toolRoots.codex`)并打印出来。`CLAUDE_CONFIG_DIR=~/.claude` 也算——它与不设置该变量并不等价:设置之后 Claude Code 从配置目录内部读取 `.claude.json`,因此 teamai 写的是 `~/.claude/.claude.json` 而不是 `~/.claude.json`。读取这些变量的命令也只有 `init`——它们只存在于某一份 shell 配置里,而 teamai 还会从 session hook 和别的终端里运行,每次运行都去读它,同步目标就会取决于是谁启动了进程。重新执行 `init` 会保留之前记录的根目录,所以在没有该变量的 shell 里再跑一次 init,同步目标不会被悄悄改回默认位置。如果重新执行 `init` 确实换了根目录,teamai 会把此前注入到旧根目录设置文件(`settings.json`,Codex 为 `hooks.json`)里的 hook 移除,以免那个工具继续往新目录同步;写在旧目录里的 skills、rules 和指令文件会原样保留,并在输出中指明位置。project scope 的 `init` 若自身没有记录、也读不到该变量,则沿用 user scope 的记录:根目录是这台机器的事实,而 project scope 的 hook 也注入到 home 目录。要结束迁移,把该变量设为空再执行一次 `init`(`CLAUDE_CONFIG_DIR= teamai init …`、`CODEX_HOME= teamai init …`):记录会被清除,旧根目录按同样方式释放。除 hook 之外,旧根目录里 teamai 管理的 MCP server,以及(Claude Code 的)本地 agent 下发的网关凭据也会一并移除——它们是生效中的配置,不同于 skills 和 rules。 @@ -2637,7 +2638,7 @@ teamai pull **Q: User scope 和 Project scope 可以共存吗?** -可以,但 project scope 默认保持隔离。当前工作目录包含 project scope 配置时,该项目生效并跳过 user scope。先初始化 user scope,再使用 `--inherit-user-scope` 初始化项目(或在项目本地配置中设置 `inheritUserScope: true`),即可组合安全资源和 Recall 结果;可执行配置和控制面配置(`env`、MCP)仍只使用 project scope;hooks 例外——非-self 的 project scope 会把 hooks 注入到 HOME,以便 `hook-dispatch` 依据 `cwd` 门控(详见 Hooks 章节)。 +可以,但 project scope 默认保持隔离。当前工作目录包含 project scope 配置时,该项目生效并跳过 user scope。先初始化 user scope,再使用 `--inherit-user-scope` 初始化项目(或在项目本地配置中设置 `inheritUserScope: true`),即可组合安全资源和 Recall 结果;可执行配置和控制面配置(`env`、MCP)仍只使用 project scope;hooks 例外——非-self 的 project scope 会把内置 hooks 注入到 HOME,以便 `hook-dispatch` 依据 `cwd` 门控(详见 Hooks 章节)。 **Q: `teamai init` 提示已初始化?** diff --git a/skill-data/core/references/troubleshooting.md b/skill-data/core/references/troubleshooting.md index 159c029bb..91d49577e 100644 --- a/skill-data/core/references/troubleshooting.md +++ b/skill-data/core/references/troubleshooting.md @@ -28,8 +28,10 @@ This is the #1 onboarding issue. In order: ```bash teamai hooks inject ``` -4. **Wrong scope?** Project-scope hooks are written to your HOME tool settings - (e.g. `~/.claude/settings.json`), not the project folder — that is intentional. +4. **Wrong scope?** Project-scope built-in hooks are written to your HOME tool + settings (e.g. `~/.claude/settings.json`), not the project folder; the team's own + hooks for Claude Code and Codex go to the main checkout + (`.claude/settings.local.json`, `.codex/hooks.json`). That is intentional. If you initialized project scope but expected machine-wide resources, re-run with `--scope user`. 5. **Tool has no hook surface** (e.g. Gemini CLI, JoyCode): there is no auto-sync; @@ -142,7 +144,7 @@ broken machine): | Tool | Hooks status | Why | |-----------------------|---------------------------|---------------------------------------------------------------------| | Claude Code (`claude`)| Installed | Fully supported — this is the main, working path | -| Codex | Written but **trust-gated** or skipped | Codex gates non-managed hooks behind an explicit trust step; `teamai doctor` prints a reminder to trust them | +| Codex | Installed and trusted | Codex runs only trusted hooks; teamai trusts the ones it writes through `codex app-server`, and `teamai doctor` names any Codex will not run | | Cursor | Installed | Also runs `~/.claude/settings.json`. That copy exits only when `~/.cursor/hooks.json` or the project `.cursor/hooks.json` contains `--tool cursor` | | Copilot CLI | Installed in self mode | Also runs a trusted project's `.claude/settings.json`. That copy exits only when `.github/hooks/teamai.json` contains `--tool copilot`. `COPILOT_CLI` alone does not skip | | CodeBuddy / WorkBuddy | Installed | Claude-format hooks in their own `settings.json` | @@ -161,11 +163,14 @@ step — do not assume auto-sync just works. ### Codex -Codex gates non-managed hooks behind an explicit **trust** step. `teamai init` / -`teamai hooks inject` may write the hooks, but Codex won't run them until the user -trusts them (`teamai doctor` prints a reminder when it detects this). Guide the -user to trust the teamai hooks in Codex, then reopen a session. Until then, run -`teamai pull` manually. +Codex runs a non-managed hook only once it is **trusted**. `teamai init`, `pull` +and `teamai hooks inject` trust the hooks they write (and, in a project, the main +checkout) through `codex app-server`; trust written by a session-start pull applies +from the next Codex session. `teamai doctor` names any teamai hook Codex will not +run. Then: run `teamai pull`; if `codex` is not on PATH or `codexTrustEnabled: false` +is set in `config.yaml`, guide the user to trust the teamai hooks in Codex `/hooks`, +then reopen a session. A new linked worktree gets the team hooks from its second +Codex session (the first creates its `.codex/`). ### Cursor diff --git a/skill-data/setup/SKILL.md b/skill-data/setup/SKILL.md index 683133311..0ab1c9129 100644 --- a/skill-data/setup/SKILL.md +++ b/skill-data/setup/SKILL.md @@ -47,7 +47,7 @@ and create-repo URLs, and the per-provider caveats, and points at `--agent` gives an interactive picker; select all detected tools). **After init, report which agents were set up** — in the user's language, which tools now auto-start TeamAI, and which detected tools were skipped and why (e.g. Codex - trust-gate, CodeBuddy design). Verify the real per-tool result with + hooks not trusted, CodeBuddy design). Verify the real per-tool result with `teamai doctor` and `teamai hooks list`. 3. **After init, resources appear on the NEXT session.** `teamai init` injects a session-start hook that auto-runs `teamai pull`. Empty skills/rules directories diff --git a/skill-data/setup/references/setup-admin.md b/skill-data/setup/references/setup-admin.md index 6194c71b9..c10901940 100644 --- a/skill-data/setup/references/setup-admin.md +++ b/skill-data/setup/references/setup-admin.md @@ -213,7 +213,7 @@ login` run in an interactive shell (see Step 3). Claude Code"). Omitting `--agent` gives an interactive picker — select **every AI tool already installed** on the machine. Then **report back which agents were set up**, in the user's language: name the tools that will now auto-start TeamAI, and -any detected tool that was skipped and why (e.g. Codex trust-gate, +any detected tool that was skipped and why (e.g. Codex hooks not trusted, CodeBuddy/WorkBuddy by design — see the troubleshooting reference, `"$(teamai skill path core)/references/troubleshooting.md"`). ## Step 6 — Verify with doctor diff --git a/src/__tests__/codex-trust.test.ts b/src/__tests__/codex-trust.test.ts new file mode 100644 index 000000000..dc0dd2eaf --- /dev/null +++ b/src/__tests__/codex-trust.test.ts @@ -0,0 +1,408 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import path from 'node:path'; +import os from 'node:os'; +import { spawnSync } from 'node:child_process'; +import fse from 'fs-extra'; + +vi.mock('../utils/logger.js', () => ({ + log: { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), persist: vi.fn() }, +})); + +import { readCodexHookTrustForScope, reconcileTeamHooksForConfig, reportCodexTrust, trustCodexForScope } from '../hooks.js'; +import { log } from '../utils/logger.js'; +import type { LocalConfig, TeamaiConfig } from '../types.js'; +import { installFakeCodex, readFakeCodexState, writeFakeCodexOptions } from './helpers/fake-codex.js'; + +let home: string; +let repo: string; +let fakeBin: string; + +const teamConfig = { + toolPaths: { + codex: { settings: '.codex/hooks.json' }, + 'codex-internal': { settings: '.codex-internal/hooks.json' }, + }, +} as unknown as TeamaiConfig; + +function userConfig(extra: Partial = {}): LocalConfig { + return { + repo: { localPath: repo, remote: 'x' }, + username: 'u', + scope: 'user', + additionalRoles: [], + ...extra, + } as unknown as LocalConfig; +} + +async function writeYaml(content: string): Promise { + await fse.ensureDir(path.join(repo, 'hooks')); + await fse.writeFile(path.join(repo, 'hooks', 'hooks.yaml'), content); +} + +const LINT_HOOK = ` +hooks: + - id: lint + description: run lint before a tool + event: PreToolUse + command: npm run lint +`; + +/** What init / `hooks inject` / pull do: write the hooks, then trust them. */ +async function writeAndTrust(config: LocalConfig, opts: { dryRun?: boolean; removeAll?: boolean } = {}) { + const reconciled = await reconcileTeamHooksForConfig(teamConfig, config, opts); + const codexTrust = opts.dryRun || opts.removeAll ? undefined : await trustCodexForScope(teamConfig, config); + return { ...reconciled, codexTrust }; +} + +function codexHome(): string { + return path.join(home, '.codex'); +} + +function trustedKeys(dir = codexHome()): string[] { + return Object.keys(readFakeCodexState(dir).hooksState); +} + +function calls(method: string, dir = codexHome()): number { + return readFakeCodexState(dir).calls.filter((c) => c.method === method).length; +} + +/** Every handler in a Codex hooks.json, with the trust key Codex gives it. */ +async function codexEntries(file: string): Promise> { + const json = await fse.readJson(file) as { hooks: Record }>> }; + const real = await fse.realpath(file); + const snake = (e: string): string => e.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase(); + return Object.entries(json.hooks).flatMap(([event, groups]) => groups.flatMap((g, gi) => + g.hooks.map((h, hi) => ({ key: `${real}:${snake(event)}:${gi}:${hi}`, command: h.command })))); +} + +beforeEach(async () => { + home = await fse.realpath(await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-codex-trust-home-'))); + repo = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-codex-trust-repo-')); + fakeBin = installFakeCodex(); + vi.stubEnv('HOME', home); + vi.stubEnv('PATH', `${fakeBin}${path.delimiter}${process.env.PATH ?? ''}`); + await fse.ensureDir(codexHome()); + vi.mocked(log.warn).mockClear(); + vi.mocked(log.success).mockClear(); +}); + +afterEach(async () => { + vi.unstubAllEnvs(); + await fse.remove(home); + await fse.remove(repo); + await fse.remove(fakeBin); +}); + +describe('Codex hook trust — user scope', () => { + it('trusts every hook teamai wrote and leaves the member\'s own hook alone', async () => { + await writeYaml(LINT_HOOK); + await fse.writeJson(path.join(codexHome(), 'hooks.json'), { + hooks: { Stop: [{ hooks: [{ type: 'command', command: 'echo mine' }] }] }, + }); + + const reconciled = await writeAndTrust(userConfig()); + + const entries = await codexEntries(path.join(codexHome(), 'hooks.json')); + const teamai = entries.filter((e) => e.command !== 'echo mine'); + expect(teamai.some((e) => e.command === 'npm run lint')).toBe(true); + expect(trustedKeys().sort()).toEqual(teamai.map((e) => e.key).sort()); + expect(reconciled.codexTrust).toEqual({ kind: 'trusted', hooks: teamai.length }); + }); + + it('writes nothing to Codex when every teamai hook is already trusted', async () => { + await writeYaml(LINT_HOOK); + await writeAndTrust(userConfig()); + + const again = await writeAndTrust(userConfig()); + + expect(calls('config/batchWrite')).toBe(1); + expect(again.codexTrust).toEqual({ kind: 'trusted', hooks: 0 }); + }); + + it('re-trusts a teamai hook whose command changed', async () => { + await writeYaml(LINT_HOOK); + await writeAndTrust(userConfig()); + await writeYaml(LINT_HOOK.replace('npm run lint', 'npm run lint:fix')); + + const again = await writeAndTrust(userConfig()); + + expect(again.codexTrust).toEqual({ kind: 'trusted', hooks: 1 }); + }); + + it('runs the app-server against the Codex home teamai writes to (toolRoots.codex)', async () => { + const relocated = path.join(home, '.codex-work'); + await fse.ensureDir(relocated); + + const reconciled = await writeAndTrust(userConfig({ toolRoots: { codex: '~/.codex-work' } })); + + expect(reconciled.codexTrust?.kind).toBe('trusted'); + expect(trustedKeys(relocated).length).toBeGreaterThan(0); + expect(calls('hooks/list')).toBe(0); + }); + + it('reports Codex as unavailable when it is not on PATH', async () => { + const empty = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-codex-trust-empty-')); + vi.stubEnv('PATH', empty); + try { + const reconciled = await writeAndTrust(userConfig()); + expect(reconciled.codexTrust).toEqual({ kind: 'unavailable', reason: 'codex is not on PATH' }); + } finally { + await fse.remove(empty); + } + }); + + it('reports the failing call when the app-server answers with an error', async () => { + writeFakeCodexOptions(codexHome(), { failMethod: 'hooks/list' }); + + const reconciled = await writeAndTrust(userConfig()); + + expect(reconciled.codexTrust).toEqual({ kind: 'failed', reason: 'hooks/list: fake failure' }); + }); + + it('reports a failure when the app-server exits before answering', async () => { + writeFakeCodexOptions(codexHome(), { exit: true }); + + const reconciled = await writeAndTrust(userConfig()); + + expect(reconciled.codexTrust?.kind).toBe('failed'); + }); + + it('does not touch Codex when the member turned it off', async () => { + const reconciled = await writeAndTrust(userConfig({ codexTrustEnabled: false })); + + expect(reconciled.codexTrust).toEqual({ kind: 'disabled' }); + expect(readFakeCodexState(codexHome()).calls).toEqual([]); + }); + + it('does not run for the internal Codex variants, which have no trust gate', async () => { + await fse.remove(codexHome()); + await fse.ensureDir(path.join(home, '.codex-internal')); + + const reconciled = await writeAndTrust(userConfig()); + + expect(reconciled.codexTrust).toBeUndefined(); + expect(await fse.pathExists(path.join(home, '.codex-internal', 'fake-state.json'))).toBe(false); + }); + + it('has nothing to trust after a dry run or a removal', async () => { + await writeAndTrust(userConfig(), { dryRun: true }); + await writeAndTrust(userConfig(), { removeAll: true }); + expect(await trustCodexForScope(teamConfig, userConfig())).toBeUndefined(); + + expect(readFakeCodexState(codexHome()).calls).toEqual([]); + }); + + it('still trusts the built-in hooks when the team hooks do not resolve', async () => { + await writeYaml('hooks: [unclosed'); + + const reconciled = await writeAndTrust(userConfig()); + + expect(reconciled.ok).toBe(false); + expect(reconciled.codexTrust?.kind).toBe('trusted'); + expect(trustedKeys().length).toBeGreaterThan(0); + }); +}); + +describe('reportCodexTrust', () => { + it('names how many hooks it trusted', () => { + reportCodexTrust({ kind: 'trusted', hooks: 3 }, 'all'); + expect(log.success).toHaveBeenCalledWith('Trusted 3 teamai hook(s) in Codex'); + }); + + it('falls back to the trust reminder when Codex is missing, and stays quiet during a pull', () => { + reportCodexTrust({ kind: 'unavailable', reason: 'codex is not on PATH' }, 'problems'); + expect(log.warn).not.toHaveBeenCalled(); + reportCodexTrust({ kind: 'unavailable', reason: 'codex is not on PATH' }, 'all'); + expect(vi.mocked(log.warn).mock.calls[0]?.[0]).toMatch(/open \/hooks/); + }); + + it('gives the cause of a failure, also during a pull', () => { + reportCodexTrust({ kind: 'failed', reason: 'hooks/list: boom' }, 'problems'); + expect(vi.mocked(log.warn).mock.calls[0]?.[0]).toMatch(/^Could not trust the teamai hooks in Codex \(hooks\/list: boom\)\./); + }); + + it('says a project the member marked untrusted was left so', () => { + reportCodexTrust({ kind: 'project-untrusted', hooks: 0, project: '/p' }, 'problems'); + expect(vi.mocked(log.warn).mock.calls[0]?.[0]).toMatch(/Codex marks \/p as untrusted/); + }); +}); + +describe('Codex trust — skipping a pass that has nothing new', () => { + it('does not start Codex again while nothing it depends on changed', async () => { + await writeAndTrust(userConfig()); + await writeAndTrust(userConfig()); + + expect(calls('initialize')).toBe(1); + }); + + it('asks Codex again once its config changed (the member untrusted a hook)', async () => { + await writeAndTrust(userConfig()); + await fse.appendFile(path.join(codexHome(), 'config.toml'), '\n# edited\n'); + + await writeAndTrust(userConfig()); + + expect(calls('initialize')).toBe(2); + }); + + it('asks Codex again when forced (init, hooks inject)', async () => { + await writeAndTrust(userConfig()); + + await trustCodexForScope(teamConfig, userConfig(), { force: true }); + + expect(calls('initialize')).toBe(2); + }); + + it('asks Codex again after the codex binary changed (an upgrade can change its hashes)', async () => { + await writeAndTrust(userConfig()); + await fse.appendFile(path.join(fakeBin, 'codex'), '# upgraded\n'); + + await writeAndTrust(userConfig()); + + expect(calls('initialize')).toBe(2); + }); + + it('keeps asking while the last pass did not end trusted', async () => { + writeFakeCodexOptions(codexHome(), { failMethod: 'hooks/list' }); + await writeAndTrust(userConfig()); + await writeAndTrust(userConfig()); + + expect(calls('initialize')).toBe(2); + }); +}); + +describe('readCodexHookTrustForScope (doctor)', () => { + it('names the teamai hooks Codex will not run, and writes nothing', async () => { + await writeYaml(LINT_HOOK); + await writeAndTrust(userConfig({ codexTrustEnabled: false })); + + const report = await readCodexHookTrustForScope(teamConfig, userConfig()); + + expect(report?.kind).toBe('listed'); + const notTrusted = report?.kind === 'listed' ? report.notTrusted : []; + expect(notTrusted.map((h) => h.command)).toContain('npm run lint'); + expect(notTrusted.every((h) => h.status === 'untrusted')).toBe(true); + expect(calls('config/batchWrite')).toBe(0); + }); + + it('reports nothing missing once teamai trusted its hooks', async () => { + await writeYaml(LINT_HOOK); + await writeAndTrust(userConfig()); + + expect(await readCodexHookTrustForScope(teamConfig, userConfig())).toEqual({ kind: 'listed', notTrusted: [] }); + }); + + it('is null when teamai wrote no Codex hook', async () => { + expect(await readCodexHookTrustForScope(teamConfig, userConfig())).toBeNull(); + }); +}); + +describe('Codex trust — project scopes', () => { + let project: string; + + beforeEach(async () => { + project = await fse.realpath(await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-codex-trust-proj-'))); + }); + afterEach(async () => { + await fse.remove(project); + }); + + function projectConfig(extra: Partial = {}): LocalConfig { + return userConfig({ scope: 'project', projectRoot: project, ...extra }); + } + + function selfConfig(root = project): LocalConfig { + return userConfig({ + scope: 'project', + projectRoot: root, + repo: { localPath: path.join(root, '.teamai'), remote: 'x', kind: 'self', businessRepoRoot: root }, + } as Partial); + } + + it('self mode trusts the repo, then every teamai hook in its .codex/hooks.json', async () => { + await fse.ensureDir(path.join(project, '.codex')); + await fse.outputFile(path.join(project, '.teamai', 'hooks', 'hooks.yaml'), LINT_HOOK); + + const result = await writeAndTrust(selfConfig()); + + const state = readFakeCodexState(codexHome()); + expect(state.projects[project]).toEqual({ trust_level: 'trusted' }); + const methods = state.calls.map((c) => c.method); + expect(methods.indexOf('config/batchWrite')).toBeLessThan(methods.indexOf('hooks/list')); + const entries = await codexEntries(path.join(project, '.codex', 'hooks.json')); + expect(entries.some((e) => e.command === 'npm run lint')).toBe(true); + expect(trustedKeys().sort()).toEqual(entries.map((e) => e.key).sort()); + expect(result.codexTrust).toEqual({ kind: 'trusted', hooks: entries.length, project }); + }); + + it('a self worktree trusts the main checkout\'s hooks, which Codex reads there', async () => { + const git = (cwd: string, ...args: string[]) => { + const r = spawnSync('git', args, { cwd, encoding: 'utf8' }); + if (r.status !== 0) throw new Error(r.stderr); + }; + git(project, 'init', '-q'); + git(project, '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-q', '--allow-empty', '-m', 'init'); + const worktree = `${project}-wt`; + git(project, 'worktree', 'add', '-q', '--detach', worktree); + try { + for (const root of [project, worktree]) { + await fse.ensureDir(path.join(root, '.codex')); + await reconcileTeamHooksForConfig(teamConfig, selfConfig(root)); + } + writeFakeCodexOptions(codexHome(), { projectLayers: { [worktree]: project } }); + + await trustCodexForScope(teamConfig, selfConfig(worktree)); + + const mainEntries = await codexEntries(path.join(project, '.codex', 'hooks.json')); + expect(trustedKeys()).toEqual(expect.arrayContaining(mainEntries.map((e) => e.key))); + expect(readFakeCodexState(codexHome()).projects[project]).toEqual({ trust_level: 'trusted' }); + } finally { + git(project, 'worktree', 'remove', '--force', worktree); + } + }); + + it('trusts the main checkout and its team hooks in a project scope', async () => { + await writeYaml(LINT_HOOK); + + const result = await writeAndTrust(projectConfig()); + + expect(readFakeCodexState(codexHome()).projects[project]).toEqual({ trust_level: 'trusted' }); + const team = await codexEntries(path.join(project, '.codex', 'hooks.json')); + const builtins = await codexEntries(path.join(codexHome(), 'hooks.json')); + expect(team.map((e) => e.command)).toEqual(['npm run lint']); + expect(trustedKeys().sort()).toEqual([...team, ...builtins].map((e) => e.key).sort()); + expect(result.codexTrust).toMatchObject({ kind: 'trusted', project }); + }); + + it('leaves the project alone when nothing of teamai\'s is in its .codex/', async () => { + const result = await writeAndTrust(projectConfig()); + + expect(readFakeCodexState(codexHome()).projects).toEqual({}); + expect(result.codexTrust).toEqual({ kind: 'trusted', hooks: expect.any(Number) }); + }); + + it('trusts the project for the Codex project MCP servers teamai wrote, with no team hook', async () => { + const config = projectConfig(); + const record = path.join(project, '.teamai', 'workspaces'); + const { managedMcpManifestKey, managedMcpManifestPath, getDataHome } = await import('../types.js'); + await fse.outputJson(managedMcpManifestPath(getDataHome(config), project), { + [managedMcpManifestKey('codex', true)]: [{ name: 'demo', hash: 'x' }], + }); + expect(await fse.pathExists(record)).toBe(true); + + await writeAndTrust(config); + + expect(readFakeCodexState(codexHome()).projects[project]).toEqual({ trust_level: 'trusted' }); + }); + + it('keeps a project the member marked untrusted, and says so', async () => { + await writeYaml(LINT_HOOK); + await fse.outputJson(path.join(codexHome(), 'fake-state.json'), { + projects: { [project]: { trust_level: 'untrusted' } }, hooksState: {}, calls: [], + }); + + const result = await writeAndTrust(projectConfig()); + + expect(readFakeCodexState(codexHome()).projects[project]).toEqual({ trust_level: 'untrusted' }); + expect(result.codexTrust).toMatchObject({ kind: 'project-untrusted', project }); + }); +}); diff --git a/src/__tests__/doctor.test.ts b/src/__tests__/doctor.test.ts index 3904724d6..73f93377c 100644 --- a/src/__tests__/doctor.test.ts +++ b/src/__tests__/doctor.test.ts @@ -55,11 +55,18 @@ vi.mock('../providers/tgit/index.js', () => ({ gfIsAuthenticated: vi.fn().mockResolvedValue(true), })); +// What Codex says about the hooks teamai wrote; null = teamai wrote no Codex hook. +// The app-server conversation itself is covered by codex-trust.test.ts. +vi.mock('../hooks.js', async (importOriginal) => ({ + ...(await importOriginal()), + readCodexHookTrustForScope: vi.fn().mockResolvedValue(null), +})); + // ── Imports (after mocks) ──────────────────────────────── import { loadLocalConfig, loadTeamConfig } from '../config.js'; import { pathExists, readFileSafe } from '../utils/fs.js'; -import { TEAMAI_HOOK_SUBCOMMANDS } from '../hooks.js'; +import { TEAMAI_HOOK_SUBCOMMANDS, readCodexHookTrustForScope } from '../hooks.js'; import { log, setStderrOnly } from '../utils/logger.js'; import { isGfInstalled, gfIsAuthenticated } from '../providers/tgit/index.js'; import { buildChecks, doctor, resolveDoctorContext } from '../doctor.js'; @@ -69,6 +76,7 @@ const mockedLoadLocalConfig = loadLocalConfig as Mock; const mockedLoadTeamConfig = loadTeamConfig as Mock; const mockedPathExists = pathExists as Mock; const mockedReadFileSafe = readFileSafe as Mock; +const mockedReadCodexHookTrust = readCodexHookTrustForScope as Mock; const mockedLog = log as unknown as { info: Mock; success: Mock; warn: Mock; error: Mock; debug: Mock }; const mockedIsGfInstalled = isGfInstalled as Mock; const mockedGfIsAuthenticated = gfIsAuthenticated as Mock; @@ -437,35 +445,44 @@ describe('doctor — hook checks', () => { expect(envLine).toContain('✔'); }); - it('notes Codex may require trust when Codex hooks are installed', async () => { - mockedLoadTeamConfig.mockResolvedValue({ - ...mockTeamConfig, - toolPaths: { - claude: { settings: '.claude/settings.json', skills: '.claude/skills' }, - codex: { settings: '.codex/hooks.json', skills: '.codex/skills' }, - }, - }); - // Both settings files exist and contain the hook-dispatch command. - mockedReadFileSafe.mockImplementation(async (filePath: string) => { - if (filePath.includes('settings.json') || filePath.includes('hooks.json')) { - return buildFullHooksContent(); - } - return null; + describe('Codex hook trust', () => { + const trustCheck = () => consoleSpy.mock.calls.map((c) => String(c[0])) + .filter((msg) => msg.includes('Codex trusts the teamai hooks')); + + it('passes when Codex trusts every teamai hook', async () => { + mockedReadCodexHookTrust.mockResolvedValueOnce({ kind: 'listed', notTrusted: [] }); + await doctor({}); + expect(trustCheck()).toEqual([expect.stringContaining('✔')]); }); - await doctor({}); + it('fails naming each teamai hook Codex will not run', async () => { + mockedReadCodexHookTrust.mockResolvedValueOnce({ + kind: 'listed', + notTrusted: [{ file: '/home/u/.codex/hooks.json', command: 'teamai hook-dispatch session-start', status: 'modified' }], + }); + const ok = await doctor({}); + expect(ok).toBe(false); + expect(trustCheck()).toEqual([expect.stringContaining('✖')]); + const fix = consoleSpy.mock.calls.map((c) => String(c[0])).find((msg) => msg.includes('Codex will not run')); + expect(fix).toContain('teamai hook-dispatch session-start in /home/u/.codex/hooks.json (modified)'); + expect(fix).toContain('teamai pull'); + expect(fix).toContain('codexTrustEnabled'); + }); - const infoLines = mockedLog.info.mock.calls.map((c) => String(c[0])); - const note = infoLines.find((msg) => msg.includes('review/trust')); - expect(note).toBeDefined(); - expect(note).toContain('Codex'); - }); + it('keeps the trust note when Codex cannot be asked', async () => { + mockedReadCodexHookTrust.mockResolvedValueOnce({ kind: 'unavailable', reason: 'codex not found on PATH' }); + await doctor({}); + expect(trustCheck()).toEqual([]); + const infoLines = mockedLog.info.mock.calls.map((c) => String(c[0])); + expect(infoLines.some((msg) => msg.includes('review/trust') && msg.includes('Codex'))).toBe(true); + }); - it('does not note Codex trust when no Codex hooks are installed', async () => { - // Default mockTeamConfig has only claude; readFileSafe returns full hooks. - await doctor({}); - const infoLines = mockedLog.info.mock.calls.map((c) => String(c[0])); - expect(infoLines.some((msg) => msg.includes('review/trust'))).toBe(false); + it('says nothing about Codex trust when teamai wrote no Codex hook', async () => { + await doctor({}); + expect(trustCheck()).toEqual([]); + const infoLines = mockedLog.info.mock.calls.map((c) => String(c[0])); + expect(infoLines.some((msg) => msg.includes('review/trust'))).toBe(false); + }); }); it('should skip tools whose parent directory does not exist', async () => { diff --git a/src/__tests__/helpers/clear-agent-session-env.ts b/src/__tests__/helpers/clear-agent-session-env.ts index d50871f4c..ea175d160 100644 --- a/src/__tests__/helpers/clear-agent-session-env.ts +++ b/src/__tests__/helpers/clear-agent-session-env.ts @@ -1,3 +1,7 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterAll } from 'vitest'; import { AGENT_SESSION_ENV } from '../../utils/session-id.js'; // Tests run inside an agent's shell (Claude Code sets CLAUDE_CODE_SESSION_ID), @@ -17,3 +21,17 @@ for (const name of AGENT_SESSION_ENV) { for (const name of ['CLAUDE_CONFIG_DIR', 'COPILOT_HOME', 'OPENCLAW_STATE_DIR']) { delete process.env[name]; } + +// Writing Codex hooks makes teamai run `codex app-server` to trust them (#955), +// which would edit the developer's real Codex config. Shadow any `codex` on +// PATH with one that exits at once, so every test file — and the CLIs it +// spawns — sees a failing Codex. A test that needs one prepends the fake from +// helpers/fake-codex.ts. +const shadow = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-no-codex-'))); +fs.writeFileSync(path.join(shadow, 'codex'), '#!/bin/sh\nexit 1\n'); +fs.chmodSync(path.join(shadow, 'codex'), 0o755); +fs.writeFileSync(path.join(shadow, 'codex.cmd'), '@exit /b 1\r\n'); +process.env.PATH = `${shadow}${path.delimiter}${process.env.PATH ?? ''}`; +afterAll(() => { + fs.rmSync(shadow, { recursive: true, force: true }); +}); diff --git a/src/__tests__/helpers/fake-codex.ts b/src/__tests__/helpers/fake-codex.ts new file mode 100644 index 000000000..c0e62171e --- /dev/null +++ b/src/__tests__/helpers/fake-codex.ts @@ -0,0 +1,117 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +/** + * A stand-in `codex` whose `app-server` speaks the JSON-RPC subset teamai uses + * (initialize, hooks/list, config/read, config/batchWrite), with its state in + * `$CODEX_HOME/fake-state.json`. Hooks are listed from `$CODEX_HOME/hooks.json` + * (the user layer) and from `/.codex/hooks.json` for each trusted + * project that is the cwd or one of its parents, or that `$CODEX_HOME/fake.json` + * maps the cwd to (`projectLayers`, how Codex reads a linked worktree from its + * main checkout). A hook's hash is its handler's JSON, so editing it reads as + * `modified`, as in Codex. `fake.json` can also make one method fail + * (`failMethod`) or the process exit at once (`exit`). + */ +const SERVER = String.raw` +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const home = process.env.CODEX_HOME; +const read = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } }; +const opts = read(path.join(home, 'fake.json'), {}); +if (opts.exit) process.exit(1); +const statePath = path.join(home, 'fake-state.json'); +const state = () => read(statePath, { projects: {}, hooksState: {}, calls: [] }); +const save = (s) => fs.writeFileSync(statePath, JSON.stringify(s, null, 2)); +const real = (p) => { try { return fs.realpathSync.native(p); } catch { return path.resolve(p); } }; +const snake = (e) => e.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase(); +function layer(file, source, s, out) { + const json = read(file, null); + if (!json || !json.hooks) return; + const src = real(file); + for (const [event, groups] of Object.entries(json.hooks)) { + (groups || []).forEach((g, gi) => (g.hooks || []).forEach((h, hi) => { + const key = src + ':' + snake(event) + ':' + gi + ':' + hi; + const currentHash = 'sha256:' + crypto.createHash('sha256').update(JSON.stringify(h)).digest('hex'); + const trusted = s.hooksState[key] && s.hooksState[key].trusted_hash; + const trustStatus = !trusted ? 'untrusted' : trusted === currentHash ? 'trusted' : 'modified'; + out.push({ key, command: h.command, sourcePath: src, source, currentHash, trustStatus }); + })); + } +} +function list(cwd, s) { + const out = []; + layer(path.join(home, 'hooks.json'), 'user', s, out); + const c = real(cwd); + const mapped = (opts.projectLayers || {})[c]; + const projects = mapped ? [real(mapped)] : Object.keys(s.projects).filter((p) => c === p || c.startsWith(p + path.sep)); + for (const p of projects) { + if ((s.projects[p] || {}).trust_level !== 'trusted') continue; + layer(path.join(p, '.codex', 'hooks.json'), 'project', s, out); + } + return out; +} +let buf = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { + buf += chunk; + let i; + while ((i = buf.indexOf('\n')) >= 0) { + const line = buf.slice(0, i); buf = buf.slice(i + 1); + if (!line.trim()) continue; + const m = JSON.parse(line); + const s = state(); + s.calls.push({ method: m.method, params: m.params }); + save(s); + if (m.id === undefined) continue; + const reply = (body) => process.stdout.write(JSON.stringify({ id: m.id, ...body }) + '\n'); + if (opts.failMethod === m.method) { reply({ error: { code: -32000, message: 'fake failure' } }); continue; } + if (m.method === 'initialize') reply({ result: { userAgent: 'fake' } }); + else if (m.method === 'hooks/list') reply({ result: { data: m.params.cwds.map((cwd) => ({ cwd, hooks: list(cwd, s), warnings: [] })) } }); + else if (m.method === 'config/read') reply({ result: { config: { projects: s.projects } } }); + else if (m.method === 'config/batchWrite') { + for (const e of m.params.edits) { + if (e.keyPath === 'projects') Object.assign(s.projects, e.value); + else if (e.keyPath === 'hooks.state') for (const [k, v] of Object.entries(e.value)) s.hooksState[k] = { ...(s.hooksState[k] || {}), ...v }; + } + save(s); + reply({ result: { status: 'ok' } }); + } else reply({ error: { code: -32600, message: 'unknown method ' + m.method } }); + } +}); +`; + +export interface FakeCodexState { + projects: Record; + hooksState: Record; + calls: Array<{ method: string; params: unknown }>; +} + +/** Write the fake `codex` into a new directory and return it (prepend it to PATH). */ +export function installFakeCodex(): string { + const dir = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-fake-codex-'))); + const script = path.join(dir, 'fake-codex.cjs'); + fs.writeFileSync(script, SERVER); + const node = JSON.stringify(process.execPath); + fs.writeFileSync(path.join(dir, 'codex'), `#!/bin/sh\nexec ${node} ${JSON.stringify(script)} "$@"\n`); + fs.chmodSync(path.join(dir, 'codex'), 0o755); + fs.writeFileSync(path.join(dir, 'codex.cmd'), `@"${process.execPath}" "${script}" %*\r\n`); + return dir; +} + +export function readFakeCodexState(codexHome: string): FakeCodexState { + try { + return JSON.parse(fs.readFileSync(path.join(codexHome, 'fake-state.json'), 'utf8')) as FakeCodexState; + } catch { + return { projects: {}, hooksState: {}, calls: [] }; + } +} + +export function writeFakeCodexOptions( + codexHome: string, + options: { failMethod?: string; exit?: boolean; projectLayers?: Record }, +): void { + fs.mkdirSync(codexHome, { recursive: true }); + fs.writeFileSync(path.join(codexHome, 'fake.json'), JSON.stringify(options)); +} diff --git a/src/__tests__/hooks-cmd.test.ts b/src/__tests__/hooks-cmd.test.ts index eaaa92332..646c31417 100644 --- a/src/__tests__/hooks-cmd.test.ts +++ b/src/__tests__/hooks-cmd.test.ts @@ -18,9 +18,10 @@ vi.mock('../hooks.js', async () => { reconcileHooksToAllTools: vi.fn(), reconcileTeamHooksForConfig: vi.fn(), sweepLegacyProjectHooks: vi.fn(), - hasInstalledCodexTrustGatedTool: vi.fn(), - // Keep the real reminder text so assertions verify the actual wording. - codexTrustReminder: actual.codexTrustReminder, + trustCodexForScope: vi.fn(), + resolveMainCheckoutHooks: vi.fn(), + // Keep the real report so assertions verify the actual wording. + reportCodexTrust: actual.reportCodexTrust, }; }); @@ -42,7 +43,7 @@ vi.mock('../utils/logger.js', () => ({ // ── Imports (after mocks) ──────────────────────────────── import { autoDetectInit } from '../config.js'; -import { getHookStatus, reconcileHooks, reconcileHooksToAllTools, reconcileTeamHooksForConfig, sweepLegacyProjectHooks, hasInstalledCodexTrustGatedTool } from '../hooks.js'; +import { getHookStatus, reconcileHooks, reconcileHooksToAllTools, reconcileTeamHooksForConfig, sweepLegacyProjectHooks, trustCodexForScope, resolveMainCheckoutHooks } from '../hooks.js'; import { resolveTeamHookEntries } from '../resources/hooks.js'; import { log } from '../utils/logger.js'; import { hooksInject, hooksRemove, hooksList } from '../hooks-cmd.js'; @@ -55,7 +56,8 @@ const mockedSweep = sweepLegacyProjectHooks as Mock; const mockedReconcileStandalone = reconcileHooks as Mock; const mockedReconcile = reconcileHooksToAllTools as Mock; const mockedReconcileForConfig = reconcileTeamHooksForConfig as Mock; -const mockedHasCodexTrustGated = hasInstalledCodexTrustGatedTool as Mock; +const mockedTrustCodex = trustCodexForScope as Mock; +const mockedMainCheckout = resolveMainCheckoutHooks as Mock; const mockedParseTeamHooks = resolveTeamHookEntries as Mock; /** @@ -132,7 +134,8 @@ beforeEach(() => { mockedReconcileStandalone.mockResolvedValue(undefined); mockedReconcile.mockResolvedValue(undefined); mockedReconcileForConfig.mockResolvedValue({ ok: true, defs: [] }); - mockedHasCodexTrustGated.mockResolvedValue(false); + mockedTrustCodex.mockResolvedValue(undefined); + mockedMainCheckout.mockResolvedValue(null); mockedParseTeamHooks.mockResolvedValue(hooksYaml(TEAM_DEFS)); }); @@ -160,8 +163,16 @@ describe('hooksInject', () => { expect(mockedLog.success).not.toHaveBeenCalled(); }); - it('warns to trust Codex hooks when the public Codex is installed', async () => { - mockedHasCodexTrustGated.mockResolvedValue(true); + it('trusts the Codex hooks it wrote, whatever the last pass recorded, and says how many', async () => { + mockedTrustCodex.mockResolvedValue({ kind: 'trusted', hooks: 9 }); + await hooksInject({}); + expect(mockedTrustCodex).toHaveBeenCalledWith(mockTeamConfig, mockLocalConfig, { force: true }); + expect(mockedLog.success).toHaveBeenCalledWith('Trusted 9 teamai hook(s) in Codex'); + expect(mockedLog.warn).not.toHaveBeenCalled(); + }); + + it('falls back to the trust reminder when Codex cannot be reached', async () => { + mockedTrustCodex.mockResolvedValue({ kind: 'unavailable', reason: 'codex is not on PATH' }); await hooksInject({}); expect(mockedLog.success).toHaveBeenCalledWith(expect.stringContaining('Hooks injected')); const warned = mockedLog.warn.mock.calls.map((c) => String(c[0])).join('\n'); @@ -170,14 +181,14 @@ describe('hooksInject', () => { expect(warned).toContain('/hooks'); }); - it('does not warn about Codex trust when no trust-gated Codex is installed', async () => { - mockedHasCodexTrustGated.mockResolvedValue(false); + it('does not mention Codex trust when nothing was written for the public Codex', async () => { + mockedTrustCodex.mockResolvedValue(undefined); await hooksInject({}); expect(mockedLog.warn).not.toHaveBeenCalled(); }); - it('suppresses the Codex trust reminder with --silent', async () => { - mockedHasCodexTrustGated.mockResolvedValue(true); + it('suppresses the Codex trust report with --silent', async () => { + mockedTrustCodex.mockResolvedValue({ kind: 'unavailable', reason: 'codex is not on PATH' }); await hooksInject({ silent: true }); expect(mockedLog.success).not.toHaveBeenCalled(); expect(mockedLog.warn).not.toHaveBeenCalled(); @@ -688,7 +699,7 @@ describe('hooksRemove', () => { expect.any(String), [], expect.stringContaining('managed-hooks.json'), - { removeAll: true, scope: 'user', installedBaseDir: undefined }, + { removeAll: true, scope: 'user', installedBaseDir: undefined, mainCheckout: null }, ); expect(mockedLog.success).toHaveBeenCalledWith(expect.stringContaining('Hooks removed')); }); @@ -712,7 +723,7 @@ describe('hooksRemove', () => { '/home/testuser', [], expect.any(String), - { removeAll: true, scope: 'project', installedBaseDir: '/path/to/project' }, + { removeAll: true, scope: 'project', installedBaseDir: '/path/to/project', mainCheckout: null }, ); const userManifest = mockedReconcile.mock.calls[0][3] as string; expect(userManifest).toContain('/home/testuser'); @@ -749,7 +760,7 @@ describe('hooksRemove', () => { '/path/to/project', [], expect.any(String), - { removeAll: true, scope: 'project', installedBaseDir: '/path/to/project' }, + { removeAll: true, scope: 'project', installedBaseDir: '/path/to/project', mainCheckout: null }, ); }); diff --git a/src/__tests__/hooks-reconcile-scope.test.ts b/src/__tests__/hooks-reconcile-scope.test.ts index c4a400ec1..f0a9458b6 100644 --- a/src/__tests__/hooks-reconcile-scope.test.ts +++ b/src/__tests__/hooks-reconcile-scope.test.ts @@ -53,6 +53,18 @@ function codexSettings(): Promise<{ hooks: Record>> { return fse.readJson(path.join(home, '.teamai', 'managed-hooks.json')); } +// Claude and Codex keep a non-self project's team hooks in the main checkout +// (#955), ungated; the project here is not a git repo, so it is its own main +// checkout, and with no partition its data home is /.teamai. +function claudeLocal(): Promise<{ hooks: Record }>> }> { + return fse.readJson(path.join(project, '.claude', 'settings.local.json')); +} +function codexProject(): Promise<{ hooks: Record }>> }> { + return fse.readJson(path.join(project, '.codex', 'hooks.json')); +} +function mainManifest(): Promise>> { + return fse.readJson(path.join(project, '.teamai', 'managed-main-checkout-hooks.json')); +} beforeEach(async () => { project = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-recon-proj-')); @@ -86,22 +98,33 @@ hooks: const reconciled = await reconcileTeamHooksForConfig(teamConfig, localConfig()); expect(reconciled.ok && reconciled.defs).toHaveLength(1); + // Claude and Codex: built-ins in HOME, the team hook in the main checkout, ungated. const claude = await claudeSettings(); - expect(claude.hooks.Stop).toHaveLength(2); // built-in + team - expect(claude.hooks.Stop[1].description).toBe('[teamai:hook:lint] run lint at stop'); + expect(claude.hooks.Stop).toHaveLength(1); + expect(claude.hooks.Stop[0].description?.startsWith('[teamai] ')).toBe(true); + const claudeTeam = await claudeLocal(); + expect(claudeTeam.hooks.Stop).toHaveLength(1); + expect(claudeTeam.hooks.Stop[0].description).toBe('[teamai:hook:lint] run lint at stop'); + expect(claudeTeam.hooks.Stop[0].hooks[0].command).toBe('npm run lint'); + expect(claudeTeam.hooks.SessionStart).toBeUndefined(); + // Every other tool: built-in + gated team hook in HOME. const cursor = await cursorSettings(); expect(cursor.hooks.stop).toHaveLength(2); expect(cursor.hooks.stop.some((h) => h.command.includes('npm run lint'))).toBe(true); const codex = await codexSettings(); - expect(codex.hooks.Stop).toHaveLength(2); - expect(codex.hooks.Stop.some((h) => h.hooks[0].command.includes('npm run lint'))).toBe(true); + expect(codex.hooks.Stop).toHaveLength(1); + expect(codex.hooks.Stop.some((h) => h.hooks[0].command.includes('npm run lint'))).toBe(false); + expect((await codexProject()).hooks.Stop.map((h) => h.hooks[0].command)).toEqual(['npm run lint']); const m = await manifest(); - expect(m.claude.map((r) => r.id)).toEqual(['lint']); expect(m.cursor.map((r) => r.id)).toEqual(['lint']); - expect(m.codex.map((r) => r.id)).toEqual(['lint']); + expect(m.claude).toBeUndefined(); + expect(m.codex).toBeUndefined(); + const main = await mainManifest(); + expect(main.claude.map((r) => r.id)).toEqual(['lint']); + expect(main.codex.map((r) => r.id)).toEqual(['lint']); }); it('keeps project team hooks isolated when projects share HOME', async () => { @@ -132,16 +155,22 @@ hooks: await fse.ensureDir(path.join(projectB, '.claude')); await reconcileTeamHooksForConfig(teamConfig, configB); - const claude = await claudeSettings(); - const teamCommands = claude.hooks.Stop - .filter((entry) => entry.description?.startsWith('[teamai:hook:')) - .map((entry) => entry.hooks[0].command); + const cursor = await cursorSettings(); + const teamCommands = cursor.hooks.stop + .map((entry) => entry.command) + .filter((command) => command.includes('echo project-')); expect(teamCommands).toHaveLength(2); - expect(teamCommands.some((command) => command.includes("echo project-a") && command.includes(project))).toBe(true); - expect(teamCommands.some((command) => command.includes("echo project-b") && command.includes(projectB))).toBe(true); + const [rootA, rootB] = [await fse.realpath(project), await fse.realpath(projectB)]; + expect(teamCommands.some((command) => command.includes('echo project-a') && command.includes(rootA))).toBe(true); + expect(teamCommands.some((command) => command.includes('echo project-b') && command.includes(rootB))).toBe(true); const m = await manifest(); - expect(m.claude).toHaveLength(2); + expect(m.cursor).toHaveLength(2); + + // Claude's team hooks live in each project's own checkout. + expect((await claudeLocal()).hooks.Stop.map((e) => e.hooks[0].command)).toEqual(['echo project-a']); + const claudeB = await fse.readJson(path.join(projectB, '.claude', 'settings.local.json')); + expect(claudeB.hooks.Stop.map((e: { hooks: Array<{ command: string }> }) => e.hooks[0].command)).toEqual(['echo project-b']); } finally { await fse.remove(projectB); await fse.remove(repoB); @@ -173,11 +202,14 @@ hooks: const codex = await codexSettings(); expect(codex.hooks.Stop.some((h) => h.hooks[0].command === 'npm run lint')).toBe(false); expect(codex.hooks.Stop).toHaveLength(1); + expect((await claudeLocal()).hooks.Stop).toEqual([]); + expect((await codexProject()).hooks.Stop).toEqual([]); const m = await manifest(); expect(m.claude).toBeUndefined(); expect(m.cursor).toBeUndefined(); expect(m.codex).toBeUndefined(); + expect(await mainManifest()).toEqual({}); }); it('a role switch removes the previous role\'s hooks and adds the new role\'s, built-in untouched', async () => { @@ -207,20 +239,19 @@ hooks: `); const asRole = (role: string): LocalConfig => ({ ...localConfig(), primaryRole: role, additionalRoles: [] }); - // Project scope wraps team commands in a $PWD guard, so match by inclusion. - const stopCommands = async (): Promise => (await claudeSettings()).hooks.Stop.map((h) => h.hooks[0].command); + const stopCommands = async (): Promise => (await claudeLocal()).hooks.Stop.map((h) => h.hooks[0].command); await reconcileTeamHooksForConfig(teamConfig, asRole('frontend')); expect((await stopCommands()).some((c) => c.includes('npm run lint:css'))).toBe(true); expect((await stopCommands()).some((c) => c.includes('guard-tf.sh'))).toBe(false); - expect((await manifest()).claude.map((r) => r.id)).toEqual(['stylelint']); + expect((await mainManifest()).claude.map((r) => r.id)).toEqual(['stylelint']); await reconcileTeamHooksForConfig(teamConfig, asRole('devops')); expect((await stopCommands()).some((c) => c.includes('guard-tf.sh'))).toBe(true); expect((await stopCommands()).some((c) => c.includes('npm run lint:css'))).toBe(false); const claude = await claudeSettings(); expect(claude.hooks.Stop.filter((h) => h.description?.startsWith('[teamai] '))).toHaveLength(1); - expect((await manifest()).claude.map((r) => r.id)).toEqual(['guard-tf']); + expect((await mainManifest()).claude.map((r) => r.id)).toEqual(['guard-tf']); const cursor = await cursorSettings(); expect(cursor.hooks.stop.some((h) => h.command.includes('npm run lint:css'))).toBe(false); @@ -238,14 +269,14 @@ hooks: command: npm run lint `); await reconcileTeamHooksForConfig(teamConfig, localConfig()); - const before = await claudeSettings(); + const before = await claudeLocal(); await writeYaml('hooks: [unclosed\n'); const applied = await reconcileTeamHooksForConfig(teamConfig, localConfig()); expect(applied).toEqual({ ok: false, builtins: 'defaults-where-none' }); - expect(await claudeSettings()).toEqual(before); - expect((await manifest()).claude.map((r) => r.id)).toEqual(['lint']); + expect(await claudeLocal()).toEqual(before); + expect((await mainManifest()).claude.map((r) => r.id)).toEqual(['lint']); }); // #822: `hook:` for `hooks:` parsed as "no hooks" and removed every installed @@ -254,7 +285,7 @@ hooks: const lint = '\n - id: lint\n description: lint\n event: Stop\n command: npm run lint\n'; await writeYaml(`hooks:${lint}`); await reconcileTeamHooksForConfig(teamConfig, localConfig()); - const before = await claudeSettings(); + const before = await claudeLocal(); const { log } = await import('../utils/logger.js'); vi.mocked(log.warn).mockClear(); @@ -262,8 +293,8 @@ hooks: const applied = await reconcileTeamHooksForConfig(teamConfig, localConfig()); expect(applied).toEqual({ ok: false, builtins: 'defaults-where-none' }); - expect(await claudeSettings()).toEqual(before); - expect((await manifest()).claude.map((r) => r.id)).toEqual(['lint']); + expect(await claudeLocal()).toEqual(before); + expect((await mainManifest()).claude.map((r) => r.id)).toEqual(['lint']); const warnings = vi.mocked(log.warn).mock.calls.map(([m]) => String(m)); expect(warnings).toHaveLength(1); expect(warnings[0]).toContain('hooks/hooks.yaml'); @@ -277,7 +308,7 @@ hooks: const applied = await reconcileTeamHooksForConfig(teamConfig, localConfig()); expect(applied.ok).toBe(true); - expect((await manifest()).claude.map((r) => r.id)).toEqual(['lint']); + expect((await mainManifest()).claude.map((r) => r.id)).toEqual(['lint']); }); it('still reads a hooks.yaml that declares only builtin: overrides', async () => { @@ -327,9 +358,9 @@ hooks: expect(applied).toEqual({ ok: false, builtins: 'with-overrides' }); const claude = await claudeSettings(); expect(claude.hooks.SessionStart).toHaveLength(1); - expect(claude.hooks.Stop.some((h) => h.hooks[0].command.includes('npm run lint'))).toBe(true); + expect((await claudeLocal()).hooks.Stop.some((h) => h.hooks[0].command.includes('npm run lint'))).toBe(true); expect((await cursorSettings()).hooks.stop.some((h) => h.command.includes('npm run lint'))).toBe(true); - expect((await manifest()).claude.map((r) => r.id)).toEqual(['lint']); + expect((await mainManifest()).claude.map((r) => r.id)).toEqual(['lint']); }); it('installs the built-in hooks with their defaults on a first install whose hooks.yaml does not parse', async () => { @@ -349,7 +380,7 @@ hooks: await writeYaml('hooks:\n - id: lint\n description: lint\n event: Stop\n command: npm run lint\n'); await fse.outputFile(path.join(repo, 'hooks', 'checkout', 'hooks.yaml'), 'hooks:\n - id: lint\n description: lint\n event: Stop\n command: npm run lint:checkout\n'); - const stopCommands = async (): Promise => (await claudeSettings()).hooks.Stop.map((h) => h.hooks[0]?.command ?? ''); + const stopCommands = async (): Promise => (await claudeLocal()).hooks.Stop.map((h) => h.hooks[0]?.command ?? ''); await reconcileTeamHooksForConfig(teamConfig, { ...localConfig(), projects: ['checkout'] }); expect((await stopCommands()).some((c) => c.includes('npm run lint:checkout'))).toBe(true); @@ -435,6 +466,121 @@ builtin: // 1. Hermes/OpenCode reconcile through global adapters that ignore baseDir, so // a removeAll sweep against deleted their HOME hooks. // 2. When projectRoot IS the home dir, "legacy" and "live" are the same file. +// ── Claude and Codex team hooks in the main checkout (#955) ── +// +// One set of ungated entries in the main checkout, shared by every worktree, +// so Codex has one set of trust keys and nothing per checkout to reorder. +describe('reconcileTeamHooksForConfig — team hooks in the main checkout', () => { + const STOP_LINT = 'hooks:\n - id: lint\n description: lint\n event: Stop\n command: npm run lint\n'; + + function git(cwd: string, ...args: string[]): void { + const result = spawnSync('git', args, { cwd, encoding: 'utf8' }); + if (result.status !== 0) throw new Error(`git ${args.join(' ')}: ${result.stderr}`); + } + + async function mainWithWorktree(): Promise<{ main: string; worktree: string }> { + const main = await fse.realpath(project); + git(main, 'init', '-q'); + git(main, '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-q', '--allow-empty', '-m', 'init'); + const worktree = path.join(await fse.realpath(os.tmpdir()), `teamai-recon-wt-${path.basename(main)}`); + git(main, 'worktree', 'add', '-q', '--detach', worktree); + return { main, worktree }; + } + + const gated = (root: string, command: string): string => + `if [ "$PWD" = '${root}' ] || case "$PWD" in '${root}'/*) true;; *) false;; esac; then (${command}); fi`; + + it('writes from a linked worktree into the main checkout, not the worktree', async () => { + await writeYaml(STOP_LINT); + const { main, worktree } = await mainWithWorktree(); + try { + await reconcileTeamHooksForConfig(teamConfig, { ...localConfig(), projectRoot: worktree }); + + expect((await fse.readJson(path.join(main, '.codex', 'hooks.json'))).hooks.Stop[0].hooks[0].command).toBe('npm run lint'); + expect(await fse.pathExists(path.join(main, '.claude', 'settings.local.json'))).toBe(true); + expect(await fse.pathExists(path.join(worktree, '.codex', 'hooks.json'))).toBe(false); + expect(await fse.pathExists(path.join(worktree, '.claude', 'settings.local.json'))).toBe(false); + // The SessionStart a new Codex worktree runs before it has a `.codex/`. + expect((await codexSettings()).hooks.SessionStart).toHaveLength(1); + } finally { + git(main, 'worktree', 'remove', '--force', worktree); + } + }); + + it('removes the gated entries an older CLI left for this project, and keeps another project\'s', async () => { + await writeYaml(STOP_LINT); + const { main, worktree } = await mainWithWorktree(); + const other = await fse.realpath(await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-recon-other-'))); + const removed = path.join(other, 'removed-worktree'); + try { + const roots = [main, worktree, removed, other]; + await fse.writeJson(path.join(home, '.codex', 'hooks.json'), { + hooks: { Stop: roots.map((root) => ({ hooks: [{ type: 'command', command: gated(root, 'npm run lint') }] })) }, + }); + await fse.outputJson(path.join(home, '.teamai', 'managed-hooks.json'), { + codex: roots.map((root) => ({ id: 'lint', event: 'Stop', command: gated(root, 'npm run lint') })), + }); + + await reconcileTeamHooksForConfig(teamConfig, { ...localConfig(), projectRoot: worktree }); + + const stop = (await codexSettings()).hooks.Stop.map((h) => h.hooks[0].command); + expect(stop.filter((c) => c.startsWith('if [ "$PWD"'))).toEqual([gated(other, 'npm run lint')]); + expect(((await manifest()).codex as unknown as Array<{ command: string }>).map((r) => r.command)).toEqual([gated(other, 'npm run lint')]); + } finally { + git(main, 'worktree', 'remove', '--force', worktree); + await fse.remove(other); + } + }); + + it('creates nothing in the business repo when the team has no hooks', async () => { + await reconcileTeamHooksForConfig(teamConfig, localConfig()); + + expect(await fse.pathExists(path.join(project, '.codex'))).toBe(false); + expect(await fse.pathExists(path.join(project, '.claude', 'settings.local.json'))).toBe(false); + }); + + it('replaces a copy a pre-#370 CLI left in the main checkout\'s Codex file instead of duplicating it', async () => { + await writeYaml(STOP_LINT); + await fse.outputJson(path.join(project, '.codex', 'hooks.json'), { + hooks: { + SessionStart: [{ hooks: [{ type: 'command', command: 'teamai hook-dispatch session-start --tool codex' }] }], + Stop: [{ hooks: [{ type: 'command', command: 'npm run lint' }] }], + }, + }); + + await reconcileTeamHooksForConfig(teamConfig, localConfig()); + + const file = await codexProject(); + expect(file.hooks.SessionStart).toEqual([]); + expect(file.hooks.Stop.map((h) => h.hooks[0].command)).toEqual(['npm run lint']); + }); + + it('is a no-op on a second run', async () => { + await writeYaml(STOP_LINT); + await reconcileTeamHooksForConfig(teamConfig, localConfig()); + const read = async () => Promise.all([ + fse.readFile(path.join(home, '.codex', 'hooks.json'), 'utf8'), + fse.readFile(path.join(project, '.codex', 'hooks.json'), 'utf8'), + fse.readFile(path.join(project, '.claude', 'settings.local.json'), 'utf8'), + ]); + const before = await read(); + + await reconcileTeamHooksForConfig(teamConfig, localConfig()); + + expect(await read()).toEqual(before); + }); + + it('removeAll clears the main checkout\'s team hooks too', async () => { + await writeYaml(STOP_LINT); + await reconcileTeamHooksForConfig(teamConfig, localConfig()); + + await reconcileTeamHooksForConfig(teamConfig, localConfig(), { removeAll: true }); + + expect((await codexProject()).hooks.Stop).toEqual([]); + expect((await claudeLocal()).hooks.Stop).toEqual([]); + }); +}); + describe('reconcileTeamHooksForConfig — legacy projectRoot sweep', () => { const withOpencodeAndHermes = { toolPaths: { @@ -559,10 +705,14 @@ hooks: it('keeps the POSIX gate for a tool whose runner is not cmd.exe', async () => { const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); try { - await writeYaml(telemetryYaml('claude')); - await reconcileTeamHooksForConfig(teamConfig, localConfig()); - - const [command] = await teamStopCommands('.claude/settings.json'); + await writeYaml(telemetryYaml('workbuddy')); + await fse.ensureDir(path.join(home, '.workbuddy')); + await reconcileTeamHooksForConfig( + { toolPaths: { workbuddy: { settings: '.workbuddy/settings.json' } } } as unknown as TeamaiConfig, + localConfig(), + ); + + const [command] = await teamStopCommands('.workbuddy/settings.json'); expect(command.startsWith('if [ "$PWD" = ')).toBe(true); expect(command.endsWith('); fi')).toBe(true); } finally { diff --git a/src/__tests__/init.test.ts b/src/__tests__/init.test.ts index d9f8ad5a5..0d39cb786 100644 --- a/src/__tests__/init.test.ts +++ b/src/__tests__/init.test.ts @@ -127,6 +127,8 @@ vi.mock('../hooks.js', async (importOriginal) => ({ reconcileTeamHooksForConfig: vi.fn(async () => ({ ok: true, defs: [] })), hasTeamaiHooks: vi.fn(async () => true), reconcileHooks: vi.fn(), + trustCodexForScope: vi.fn(async () => undefined), + reportCodexTrust: vi.fn(), })); const mockDeployBuiltinSkills = vi.fn().mockResolvedValue(0); diff --git a/src/__tests__/pull-codex-trust.test.ts b/src/__tests__/pull-codex-trust.test.ts new file mode 100644 index 000000000..a4ccc17ec --- /dev/null +++ b/src/__tests__/pull-codex-trust.test.ts @@ -0,0 +1,154 @@ +/** + * The Codex trust step of a pull runs once per scope after BOTH the hooks and + * the MCP reconcile (#955): a project whose only Codex content is the project + * MCP servers that pull just wrote (#954) must end that same pull with the + * main checkout trusted, or Codex never reads its `.codex/config.toml`. + * + * Same harness as pull-dry-run-hooks-mcp.test.ts: the order is a property of + * the orchestration in pull, so that is where it is pinned. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fse from 'fs-extra'; +import os from 'node:os'; +import path from 'node:path'; + +vi.mock('../config.js', async (importOriginal) => ({ + ...(await importOriginal()), + detectProjectConfig: vi.fn().mockResolvedValue(null), + loadLocalConfigForScope: vi.fn(), + loadStateForScope: vi.fn().mockResolvedValue({ lastPull: null, lastPullRev: null }), + loadTeamConfig: vi.fn(), + requireInit: vi.fn(), + saveStateForScope: vi.fn(), +})); + +vi.mock('../utils/git.js', async (importOriginal) => ({ + ...await importOriginal(), + getHeadRev: vi.fn().mockResolvedValue('abc1234'), + pullRepo: vi.fn().mockResolvedValue('already up to date'), +})); + +vi.mock('../utils/logger.js', () => ({ + log: { + debug: vi.fn(), error: vi.fn(), info: vi.fn(), success: vi.fn(), warn: vi.fn(), dim: vi.fn(), persist: vi.fn(), + }, + spinner: vi.fn(() => ({ + fail: vi.fn().mockReturnThis(), info: vi.fn().mockReturnThis(), + start: vi.fn().mockReturnThis(), stop: vi.fn().mockReturnThis(), + succeed: vi.fn().mockReturnThis(), warn: vi.fn().mockReturnThis(), + })), +})); + +vi.mock('../roles.js', () => ({ + loadRolesManifest: vi.fn().mockResolvedValue({ + version: 1, + roles: [{ + id: 'dev', + name: 'Dev', + description: '', + resources: { knowledge: ['common'], skills: ['common'], learnings: ['common'], agents: [] }, + }], + defaults: { shareTarget: 'primary-role' }, + }), + resolveRoleResourceNamespaces: vi.fn(() => ({ + knowledge: ['common'], skills: ['common'], learnings: ['common'], agents: [], + })), + // The entry resolver asks which roles this member holds, to apply the 0.25.0 + // per-entry `roles:` rule. Without it the mock is incomplete and the + // resolution throws, which pull swallows into a debug line. + activeRoleIds: vi.fn(() => ['dev']), +})); + +// Isolation: pull() takes a real ~/.teamai/.sync-lock. Parallel vitest workers +// sharing that path race and skip/error, so these tests mock the lock. +vi.mock('../update.js', () => ({ + acquireLock: vi.fn().mockResolvedValue(true), + releaseLock: vi.fn().mockResolvedValue(undefined), +})); + +// The end-of-pull checks are exercised in pull-post-checks.test.ts; keep them +// out of the way here so a warning under test is the only thing on the wire. +vi.mock('../doctor.js', async (importOriginal) => ({ + ...await importOriginal(), + resolveDoctorContext: vi.fn(), + buildChecks: vi.fn(), +})); + +// Mocked so the dry-run forwarding can be asserted on the arguments. The real +// implementation is covered by mcp-reconcile.test.ts; this file is about the +// wiring in pull. The spread keeps every other export real, so a symbol this +// file does not know about still resolves. +vi.mock('../mcp-reconcile.js', async (importOriginal) => ({ + ...await importOriginal(), + reconcileMcpForConfig: vi.fn().mockResolvedValue({ changes: [], wrote: false }), +})); + +import { detectProjectConfig, loadLocalConfigForScope, loadTeamConfig } from '../config.js'; +import { pull } from '../pull.js'; +import { reconcileMcpForConfig } from '../mcp-reconcile.js'; +import { getDataHome, managedMcpManifestKey, managedMcpManifestPath } from '../types.js'; +import type { LocalConfig, TeamaiConfig } from '../types.js'; +import { installFakeCodex, readFakeCodexState } from './helpers/fake-codex.js'; + +describe('pull trusts the Codex project after writing its MCP servers', () => { + let tempDir: string; + let homeDir: string; + let project: string; + let fakeBin: string; + + beforeEach(async () => { + tempDir = await fse.realpath(await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-pull-codex-trust-'))); + homeDir = path.join(tempDir, 'home'); + project = path.join(tempDir, 'project'); + const repoPath = path.join(tempDir, 'team-repo'); + fakeBin = installFakeCodex(); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('PATH', `${fakeBin}${path.delimiter}${process.env.PATH ?? ''}`); + await fse.ensureDir(path.join(homeDir, '.codex')); + await fse.ensureDir(project); + await fse.ensureDir(path.join(repoPath, 'manifest')); + await fse.writeFile(path.join(repoPath, 'manifest', 'roles.yaml'), 'version: 1\n'); + + const localConfig = { + repo: { localPath: repoPath, remote: 'owner/repo' }, + username: 'tester', + scope: 'project', + projectRoot: project, + primaryRole: 'dev', + additionalRoles: [], + } as unknown as LocalConfig; + const teamConfig = { + team: 'test', + description: '', + repo: 'owner/repo', + provider: 'github', + reviewers: [], + sharing: { skills: {}, rules: { enforced: [] }, docs: { localDir: '' }, env: { injectShellProfile: true } }, + toolPaths: { codex: { skills: '.codex/skills', settings: '.codex/hooks.json', mcp: '.codex/config.toml' } }, + } as unknown as TeamaiConfig; + vi.mocked(detectProjectConfig).mockResolvedValue(localConfig); + vi.mocked(loadLocalConfigForScope).mockResolvedValue(localConfig); + vi.mocked(loadTeamConfig).mockResolvedValue(teamConfig); + // What #954's reconcile records for the Codex project MCP servers it writes. + vi.mocked(reconcileMcpForConfig).mockImplementation(async (_team, config) => { + await fse.outputJson(managedMcpManifestPath(getDataHome(config), config.projectRoot), { + [managedMcpManifestKey('codex', true)]: [{ name: 'demo', hash: 'x' }], + }); + return { changes: [], wrote: true } as never; + }); + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + vi.clearAllMocks(); + await fse.remove(tempDir); + await fse.remove(fakeBin); + }); + + it('a pull that writes Codex project MCP and no team hooks ends with the project trusted', async () => { + await pull({ force: true }); + + expect(reconcileMcpForConfig).toHaveBeenCalled(); + expect(readFakeCodexState(path.join(homeDir, '.codex')).projects[project]).toEqual({ trust_level: 'trusted' }); + }); +}); diff --git a/src/__tests__/uninstall.test.ts b/src/__tests__/uninstall.test.ts index 20d26ee83..451b57fd9 100644 --- a/src/__tests__/uninstall.test.ts +++ b/src/__tests__/uninstall.test.ts @@ -1995,6 +1995,32 @@ describe('uninstall', () => { expect(targetedProjectRoot).toBe(false); }); + it('non-self project scope removes the Claude and Codex team hooks kept in the main checkout (#955)', async () => { + const projectRoot = path.join(tmpDir, 'proj-main-hooks'); + const repoPath = path.join(projectRoot, '.teamai', 'team-repo'); + const homeDir = path.join(tmpDir, 'home'); + await fse.ensureDir(repoPath); + await fse.writeFile(path.join(projectRoot, '.teamai', 'config.yaml'), 'scope: project'); + await fse.ensureDir(path.join(homeDir, '.claude')); + const teamEntry = { matcher: '*', hooks: [{ type: 'command', command: 'npm run lint' }], description: '[teamai:hook:lint] lint' }; + await fse.outputJson(path.join(projectRoot, '.claude', 'settings.local.json'), { hooks: { Stop: [teamEntry] } }); + + vi.stubEnv('HOME', homeDir); + vi.stubEnv('SHELL', '/bin/bash'); + const teamConfig = makeTeamConfig(); + const localConfig = makeLocalConfig(projectRoot, repoPath, { scope: 'project', projectRoot }); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); + + await uninstall({ force: true }); + + expect(mockReconcileHooks).toHaveBeenCalledWith( + path.join(await fse.realpath(projectRoot), '.claude', 'settings.local.json'), + 'claude', + [], + expect.objectContaining({ removeAll: true, manifestPath: expect.stringContaining('managed-main-checkout-hooks.json') }), + ); + }); + // #667: hook discovery must resolve the settings *file* at the scope hooks // were injected into, not at the config's scope. Qoder CN reads // `~/.qoder-cn/` for its user scope, so a non-self project scope (which diff --git a/src/bootstrap.ts b/src/bootstrap.ts index 80c79c117..5f4802167 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -272,7 +272,7 @@ export async function bootstrapSelfRepo( // Inject hooks so session-start pull/report fire from now on. try { - const { describeUnappliedTeamHooks, reconcileTeamHooksForConfig } = await import('./hooks.js'); + const { describeUnappliedTeamHooks, reconcileTeamHooksForConfig, reportCodexTrust, trustCodexForScope } = await import('./hooks.js'); const reconciled = await reconcileTeamHooksForConfig(teamConfig, localConfig, {}); if (!reconciled.ok) { // A session-start bootstrap is silent, so debug.log is the only trace. @@ -280,6 +280,9 @@ export async function bootstrapSelfRepo( if (silent) log.persist(message); else log.warn(message); } + const codexTrust = await trustCodexForScope(teamConfig, localConfig); + if (!silent) reportCodexTrust(codexTrust, 'all'); + else if (codexTrust) log.debug(`[bootstrap] Codex trust: ${codexTrust.kind}`); } catch (e) { log.debug(`[bootstrap] hook injection failed (non-blocking): ${(e as Error).message}`); } diff --git a/src/codex-trust.ts b/src/codex-trust.ts new file mode 100644 index 000000000..cd6306c1c --- /dev/null +++ b/src/codex-trust.ts @@ -0,0 +1,258 @@ +import { realpathSync } from 'node:fs'; +import path from 'node:path'; +import crossSpawn from 'cross-spawn'; +import { getCurrentVersion } from './package-info.js'; + +/** + * Trusting what teamai writes into Codex (#955). + * + * The public Codex runs a non-managed hook only when + * `hooks.state."".trusted_hash` in `$CODEX_HOME/config.toml` matches the + * hook's current hash, and reads a project's `.codex/` layer only when the + * project is trusted. An untrusted or modified hook is skipped without a word. + * teamai asks `codex app-server` — the same JSON-RPC calls the TUI trust prompt + * makes — so the hash is always Codex's own, never recomputed here. + */ +export type CodexTrust = + /** Every hook teamai wrote is trusted (`hooks` is how many this run had to trust). */ + | { kind: 'trusted'; hooks: number; project?: string } + /** The member keeps Codex's project choice: it is marked untrusted, so teamai left it. */ + | { kind: 'project-untrusted'; hooks: number; project: string } + /** The member turned it off (`codexTrustEnabled: false`). */ + | { kind: 'disabled' } + /** No `codex` on PATH. */ + | { kind: 'unavailable'; reason: string } + /** `codex app-server` failed, timed out or answered with an error. */ + | { kind: 'failed'; reason: string }; + +export interface CodexHookTrustRequest { + /** The Codex home teamai wrote to (`CODEX_HOME` for the app-server). */ + codexHome: string; + /** Directory whose hook layers are listed. */ + cwd: string; + /** The hooks teamai wrote, by file and command. A member's own hook is left alone. */ + hooks: Array<{ file: string; command: string }>; + /** Project to trust first, so Codex reads its `.codex/` layer (realpath). */ + project?: string; +} + +/** What Codex says about each hook teamai wrote, for `doctor`. */ +export type CodexHookTrustReport = + | { + kind: 'listed'; + /** The teamai hooks Codex will not run: listed but not trusted, or not loaded at all. */ + notTrusted: Array<{ file: string; command: string; status: string }>; + } + | { kind: 'unavailable'; reason: string } + | { kind: 'failed'; reason: string }; + +const REQUEST_TIMEOUT_MS = 10_000; + +interface HookInfo { + key: string; + command: string; + sourcePath: string; + currentHash: string; + trustStatus: string; +} + +function canonical(file: string): string { + try { return realpathSync.native(file); } catch { return path.resolve(file); } +} + +/** One `codex app-server` process speaking line-delimited JSON-RPC over stdio. */ +interface AppServer { + request(method: string, params: unknown): Promise; + close(): void; +} + +class AppServerError extends Error { + constructor(message: string, readonly unavailable = false) { + super(message); + } +} + +async function openAppServer(codexHome: string): Promise { + const child = crossSpawn('codex', ['app-server'], { + env: { ...process.env, CODEX_HOME: codexHome }, + stdio: ['pipe', 'pipe', 'ignore'], + windowsHide: true, + }); + const pending = new Map void; reject: (e: Error) => void }>(); + let nextId = 1; + let ended: AppServerError | null = null; + const fail = (error: AppServerError): void => { + if (ended) return; + ended = error; + for (const { reject } of pending.values()) reject(error); + pending.clear(); + }; + child.on('error', (e: NodeJS.ErrnoException) => { + fail(e.code === 'ENOENT' + ? new AppServerError('codex is not on PATH', true) + : new AppServerError(`could not start codex app-server: ${e.message}`)); + }); + child.on('exit', (code, signal) => { + fail(new AppServerError(`codex app-server exited (${signal ?? `code ${code}`})`)); + }); + child.stdin?.on('error', () => { /* reported by 'exit' / 'error' */ }); + + let buffer = ''; + child.stdout?.setEncoding('utf8'); + child.stdout?.on('data', (chunk: string) => { + buffer += chunk; + let newline: number; + while ((newline = buffer.indexOf('\n')) >= 0) { + const line = buffer.slice(0, newline).trim(); + buffer = buffer.slice(newline + 1); + if (!line) continue; + let message: { id?: unknown; result?: unknown; error?: { message?: string } }; + try { message = JSON.parse(line); } catch { continue; } + if (typeof message.id !== 'number') continue; // a notification + const waiter = pending.get(message.id); + if (!waiter) continue; + pending.delete(message.id); + if (message.error) waiter.reject(new AppServerError(`${waiter.method}: ${message.error.message ?? 'unknown error'}`)); + else waiter.resolve(message.result); + } + }); + + const send = (message: object): void => { + child.stdin?.write(`${JSON.stringify(message)}\n`); + }; + const server: AppServer = { + request(method, params) { + if (ended) return Promise.reject(ended); + const id = nextId++; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pending.delete(id); + reject(new AppServerError(`codex app-server did not answer ${method} within ${REQUEST_TIMEOUT_MS / 1000}s`)); + }, REQUEST_TIMEOUT_MS); + pending.set(id, { + method, + resolve: (v) => { clearTimeout(timer); resolve(v); }, + reject: (e) => { clearTimeout(timer); reject(e); }, + }); + send({ id, method, params }); + }); + }, + close() { + fail(new AppServerError('closed')); + child.stdin?.end(); + child.kill(); + }, + }; + await server.request('initialize', { clientInfo: { name: 'teamai', version: getCurrentVersion() } }); + send({ method: 'initialized' }); + return server; +} + +type AppServerFailure = { kind: 'unavailable'; reason: string } | { kind: 'failed'; reason: string }; + +async function withAppServer( + codexHome: string, + run: (server: AppServer) => Promise, +): Promise { + let server: AppServer | null = null; + try { + server = await openAppServer(codexHome); + return await run(server); + } catch (e) { + if (e instanceof AppServerError && e.unavailable) return { kind: 'unavailable', reason: e.message }; + return { kind: 'failed', reason: (e as Error).message }; + } finally { + server?.close(); + } +} + +async function batchWrite(server: AppServer, keyPath: string, value: Record): Promise { + const result = await server.request('config/batchWrite', { + edits: [{ keyPath, value, mergeStrategy: 'upsert' }], + reloadUserConfig: true, + }) as { status?: string } | null; + if (result?.status !== 'ok') { + throw new AppServerError(`config/batchWrite ${keyPath}: status ${result?.status ?? 'missing'}`); + } +} + +/** + * Trust `project` unless the member already decided. Returns its trust level + * after the call: `trusted`, or `untrusted` when the member marked it so in + * Codex — that choice is theirs and is never overwritten. + */ +async function ensureProjectTrusted(server: AppServer, project: string): Promise<'trusted' | 'untrusted'> { + const read = await server.request('config/read', {}) as + { config?: { projects?: Record | null } } | null; + const level = read?.config?.projects?.[project]?.trust_level; + if (level === 'trusted') return 'trusted'; + if (level === 'untrusted') return 'untrusted'; + await batchWrite(server, 'projects', { [project]: { trust_level: 'trusted' } }); + return 'trusted'; +} + +/** + * Mark a project trusted in Codex, the TUI's "trust this folder" answer, so + * Codex reads its `.codex/` layer (hooks, MCP servers). A project the member + * marked untrusted is left as it is. + */ +export async function trustCodexProject(req: { codexHome: string; project: string }): Promise { + const project = canonical(req.project); + return withAppServer(req.codexHome, async (server): Promise => { + const level = await ensureProjectTrusted(server, project); + return level === 'trusted' + ? { kind: 'trusted', hooks: 0, project } + : { kind: 'project-untrusted', hooks: 0, project }; + }); +} + +function hookId(file: string, command: string): string { + return `${canonical(file)}\0${command}`; +} + +async function listHooks(server: AppServer, cwd: string): Promise { + const listed = await server.request('hooks/list', { cwds: [canonical(cwd)] }) as + { data?: Array<{ hooks?: HookInfo[] }> } | null; + return listed?.data?.[0]?.hooks ?? []; +} + +/** + * Trust exactly the hooks teamai wrote: those Codex lists for `cwd` whose file + * and command are in `hooks`. Trusts the project first when one is given, + * since an untrusted project's hook layer is not read. + */ +export async function trustCodexHooks(req: CodexHookTrustRequest): Promise { + const wanted = new Set(req.hooks.map((h) => hookId(h.file, h.command))); + const project = req.project ? canonical(req.project) : undefined; + return withAppServer(req.codexHome, async (server): Promise => { + const level = project ? await ensureProjectTrusted(server, project) : undefined; + const toTrust = (await listHooks(server, req.cwd)).filter((h) => + h.trustStatus !== 'trusted' && wanted.has(hookId(h.sourcePath, h.command))); + if (toTrust.length > 0) { + await batchWrite( + server, + 'hooks.state', + Object.fromEntries(toTrust.map((h) => [h.key, { trusted_hash: h.currentHash }])), + ); + } + if (project && level === 'untrusted') return { kind: 'project-untrusted', hooks: toTrust.length, project }; + return { kind: 'trusted', hooks: toTrust.length, ...(project ? { project } : {}) }; + }); +} + +/** + * Read-only: which of the hooks teamai wrote Codex will not run for `cwd` — + * listed as untrusted or modified, or not loaded at all (an untrusted project, + * a worktree whose `.codex/` does not exist yet). Writes nothing. + */ +export async function readCodexHookTrust(req: Omit): Promise { + return withAppServer(req.codexHome, async (server): Promise => { + const listed = new Map((await listHooks(server, req.cwd)).map((h) => [hookId(h.sourcePath, h.command), h.trustStatus])); + return { + kind: 'listed', + notTrusted: req.hooks + .map((h) => ({ ...h, status: listed.get(hookId(h.file, h.command)) ?? 'not loaded' })) + .filter((h) => h.status !== 'trusted'), + }; + }); +} diff --git a/src/doctor.ts b/src/doctor.ts index 5892c955c..1a7db76a2 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -21,7 +21,7 @@ import { import { isToolInstalledForConfig } from './resources/base.js'; import { skillsDirForTool } from './resources/skills.js'; import { getsRulesFromSessionHook } from './resources/rule-format.js'; -import { TEAMAI_HOOK_SUBCOMMANDS, isCodexTrustGatedTool, codexTrustReminder } from './hooks.js'; +import { TEAMAI_HOOK_SUBCOMMANDS, isCodexTrustGatedTool, codexTrustReminder, readCodexHookTrustForScope } from './hooks.js'; import { buildDeliveryChecks, buildRulesDeliveryChecks, @@ -122,7 +122,7 @@ export interface DoctorReport { packages?: { ok: boolean; lines: string[] }; /** * Advisories that are not checks: namespace overrides, a team secret with no - * value (#875), the Codex trust-gate reminder. + * value (#875), the Codex trust reminder when Codex cannot be asked. */ notes?: string[]; } @@ -321,7 +321,9 @@ function sessionHookRulesCheck(tool: string, settingsPath: string): Check { fix: `The teamai SessionStart and SubagentStart entries in ${settingsPath} must both exist and set ` + `\`additionalContextLimit: 0\`. Without them ${tool} keeps only the start and end of a large set of ` + 'team rules and instructions, and a fresh subagent gets none. Run `teamai pull` to rewrite them' - + (isCodexTrustGatedTool(tool) ? ', then approve the changed hooks in Codex (/hooks).' : '.'), + + (isCodexTrustGatedTool(tool) + ? ' (teamai trusts them in Codex; with `codexTrustEnabled: false`, approve them in Codex /hooks).' + : '.'), }; } @@ -347,21 +349,30 @@ async function teamaiHookEntries( /** - * True if a trust-gated Codex tool (the public `codex`) already has teamai hooks - * installed on disk (settings file exists and contains the hook-dispatch - * command). Used to emit a lightweight reminder that Codex may still require the - * user to trust them. Read-only — never inspects or modifies Codex's - * [hooks.state] trust store. Internal variants are excluded (no trust gate). + * Whether the public Codex will run the hooks teamai wrote in this scope (#955), + * asked read-only through `codex app-server` `hooks/list`. A check when Codex + * answers; the trust reminder as a note when it cannot (no `codex` on PATH, the + * app-server failed). Nothing when teamai wrote no Codex hook here. */ -async function hasInstalledCodexHooks(toolPaths: TeamaiConfig['toolPaths'], baseDir: string): Promise { - for (const [tool, paths] of Object.entries(toolPaths)) { - if (!isCodexTrustGatedTool(tool) || !paths.settings) continue; - const settingsPath = path.join(baseDir, paths.settings); - if (!await pathExists(settingsPath)) continue; - const content = await readFileSafe(settingsPath); - if (content?.includes('teamai hook-dispatch')) return true; +async function codexHookTrust(ctx: DoctorContext): Promise<{ checks: Check[]; notes: string[] }> { + const report = ctx.teamConfig ? await readCodexHookTrustForScope(ctx.teamConfig, ctx.localConfig) : null; + if (!report) return { checks: [], notes: [] }; + if (report.kind !== 'listed') { + const reason = report.kind === 'failed' ? ` (could not ask Codex: ${report.reason})` : ''; + return { checks: [], notes: [`${codexTrustReminder()}${reason}`] }; } - return false; + const notTrusted = report.notTrusted.map((h) => `${h.command} in ${h.file} (${h.status})`); + return { + checks: [{ + name: 'Codex trusts the teamai hooks', + source: 'local', + check: async () => notTrusted.length === 0, + fix: `Codex will not run: ${notTrusted.join('; ')}. Run \`teamai pull\` to trust them, ` + + 'or trust them in Codex /hooks. If `codexTrustEnabled: false` is set in config.yaml, ' + + 'teamai leaves trusting them to you.', + }], + notes: [], + }; } /** @@ -598,26 +609,22 @@ export async function doctor(options: DoctorOptions): Promise { return false; } - const { localConfig, toolPaths, baseDir } = ctx; + const { localConfig } = ctx; const scope = localConfig.scope ?? 'user'; if (!jsonMode) { const scopeLabel = `${scope}${scope === 'project' && localConfig.projectRoot ? ` (${localConfig.projectRoot})` : ''}`; console.log(` Scope: ${scopeLabel}\n`); } - const results = await runChecks(await buildChecks(ctx), jsonMode ? undefined : renderResult); + // Doctor only: it spawns `codex app-server`, which the post-pull pass skips. + const codexTrust = await codexHookTrust(ctx); + const results = await runChecks([...await buildChecks(ctx), ...codexTrust.checks], jsonMode ? undefined : renderResult); let allPassed = results.every((r) => r.ok); const { pkgDoctorReport } = await import('./pkg/commands.js'); const packageReport = await pkgDoctorReport(localConfig, process.cwd()); if (packageReport && !packageReport.allPassed) allPassed = false; - // Codex trust-gate reminder: even when hooks are installed, Codex may not run - // them until the user reviews/trusts them. Note only — teamai never writes - // [hooks.state] to auto-trust. - const codexNote = await hasInstalledCodexHooks(toolPaths, baseDir) - ? codexTrustReminder() - : null; // Info, not checks: which namespace item or entry replaces which root one // (#707), a model alias an agent uses from a namespace not active here, and // how each alias agent's model resolved in each tool (#830). @@ -627,7 +634,7 @@ export async function doctor(options: DoctorOptions): Promise { ...await aliasNamespaceNotes(ctx), ...await agentModelNotes(ctx), ...(await envAdvisories(localConfig, ctx.teamConfig, ctx.teamEnv)).map(describeEnvAdvisory), - ...(codexNote ? [codexNote] : []), + ...codexTrust.notes, ]; if (jsonMode) { diff --git a/src/hooks-cmd.ts b/src/hooks-cmd.ts index 02ea17f56..9fa44502f 100644 --- a/src/hooks-cmd.ts +++ b/src/hooks-cmd.ts @@ -1,6 +1,6 @@ import path from 'node:path'; import { autoDetectInit } from './config.js'; -import { reconcileHooks, reconcileHooksToAllTools, reconcileTeamHooksForConfig, sweepLegacyProjectHooks, getHookStatus, hasInstalledCodexTrustGatedTool, codexTrustReminder, type HookStatus } from './hooks.js'; +import { reconcileHooks, reconcileHooksToAllTools, reconcileTeamHooksForConfig, sweepLegacyProjectHooks, getHookStatus, reportCodexTrust, resolveMainCheckoutHooks, trustCodexForScope, type HookStatus } from './hooks.js'; import { applyBuiltinOverride, installedBuiltinHookDefs } from './builtin-hooks.js'; import { resolveTeamHookEntries } from './resources/hooks.js'; import { describeEntryFailure, describeOrigin, reportUndeliveredEntryNotices } from './namespaced-entries.js'; @@ -98,7 +98,6 @@ export async function hooksInject(options: GlobalOptions): Promise { const { localConfig, teamConfig } = await autoDetectInit(); // Explicit user action → not gated by sharing.hooks.autoApply (auto: false). - const { baseDir } = resolveHookScope(localConfig); const reconciled = await reconcileTeamHooksForConfig(teamConfig, localConfig, { auto: false, silent: options.silent, @@ -108,19 +107,14 @@ export async function hooksInject(options: GlobalOptions): Promise { process.exitCode = 1; return; } - let codexTrustGated = false; - if (await hasInstalledCodexTrustGatedTool(teamConfig.toolPaths, baseDir)) { - codexTrustGated = true; - } + // The public Codex skips a hook it does not trust, so trust what was just + // written (#955). An explicit inject always asks Codex, whatever the last + // pass recorded. + const codexTrust = await trustCodexForScope(teamConfig, localConfig, { force: true }); if (!options.silent) { log.success('Hooks injected into all AI tool settings'); - // The public Codex gates non-managed hooks behind an explicit trust step; - // remind the user to trust them in Codex. teamai never edits [hooks.state] - // to auto-trust (constraint: reminder only, no bypass). - if (codexTrustGated) { - log.warn(codexTrustReminder()); - } + reportCodexTrust(codexTrust, 'all'); } } @@ -284,6 +278,8 @@ export async function hooksRemove(_options: GlobalOptions): Promise { removeAll: true, scope: localConfig.scope, installedBaseDir: localConfig.scope === 'project' ? localConfig.projectRoot : undefined, + // The project's Claude and Codex team hooks live in the main checkout. + mainCheckout: await resolveMainCheckoutHooks(localConfig), }); const copilotPaths = scopedToolPaths(teamConfig, localConfig)[COPILOT_TOOL_ID]; diff --git a/src/hooks.ts b/src/hooks.ts index 78e924a7c..2cc09142c 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -1,7 +1,8 @@ import { CODEX_TOOL_IDS } from './utils/tool-names.js'; import path from 'node:path'; +import { createHash } from 'node:crypto'; import { realpathSync } from 'node:fs'; -import { rm } from 'node:fs/promises'; +import { rm, stat } from 'node:fs/promises'; import { readJson, writeJson, readFileSafe, writeFile, expandHome, ensureDir, pathExists } from './utils/fs.js'; import { log } from './utils/logger.js'; import { @@ -17,13 +18,24 @@ import { scopedToolPaths, toolInstallRoot, } from './types.js'; -import type { HookDef, TeamaiConfig, LocalConfig, Scope } from './types.js'; -import { isSelfMode } from './types.js'; +import type { HookDef, TeamaiConfig, LocalConfig, ManagedMcpManifest, Scope } from './types.js'; +import { + CODEX_TOOL_ID, + DEFAULT_CODEX_ROOT, + getDataHome, + isSelfMode, + managedMcpManifestKey, + managedMcpManifestPath, + resolveToolRootDir, +} from './types.js'; import { builtinHookDefs, applyBuiltinOverride, skipToolsWithoutShell, toolUsesCmdShell } from './builtin-hooks.js'; import type { BuiltinHookOverride } from './builtin-hooks.js'; import { resolveTeamHooks } from './resources/hooks.js'; import { getUserHome } from './utils/home.js'; import { CLAUDE_HOOK_OTHER_HOST_SKIP } from './claude-hook-host.js'; +import { listWorktrees, resolveAnchors } from './utils/git.js'; +import { findOnPath } from './utils/lookpath.js'; +import type { CodexHookTrustReport, CodexTrust } from './codex-trust.js'; export { CLAUDE_HOOK_OTHER_HOST_SKIP }; @@ -189,33 +201,66 @@ function detectFormat(tool: string): ToolFormat { /** * Tools that enforce a user trust gate on non-managed hooks. Only the public - * Codex (the OpenAI / ChatGPT Codex app, tool id `codex`) does: even after - * teamai writes `/.codex/hooks.json` or `~/.codex/hooks.json`, Codex may - * skip a newly added or changed hook until the user reviews/trusts it in - * `/hooks` or Settings → Hooks. The internal variants (`codex-internal`, - * `tcodex`) share the codex hooks.json *format* but not this trust gate, so - * they are intentionally excluded. + * Codex (the OpenAI / ChatGPT Codex app, tool id `codex`) does: it skips a hook + * whose hash is not recorded as trusted in its `config.toml`, so after every + * write teamai trusts the hooks it wrote through `codex app-server` + * (codex-trust.ts, #955). The internal variants (`codex-internal`, `tcodex`) + * share the codex hooks.json *format* but not this trust gate, so they are + * intentionally excluded. */ const CODEX_TRUST_GATE_TOOLS = new Set(['codex']); /** * True for a tool that gates hooks behind an explicit user trust step (only the - * public `codex`). teamai never edits Codex's `[hooks.state]` to auto-trust — - * the reminder is UX only. Exported so `hooks inject` / `doctor` can surface it. + * public `codex`). Exported so `hooks inject` / `doctor` can surface it. */ export function isCodexTrustGatedTool(tool: string): boolean { return CODEX_TRUST_GATE_TOOLS.has(tool); } /** - * One-line reminder that Codex may require the user to trust newly written hooks - * before they run. Shared by `hooks inject` (post-write notice) and `doctor` - * (installed-hooks note) so the wording stays identical. + * One-line reminder that Codex may require the user to trust newly written + * hooks before they run, for when teamai could not trust them itself (no + * `codex` on PATH, a failure, or the member's `codexTrustEnabled: false`). + * Shared with `doctor` (installed-hooks note) so the wording stays identical. */ export function codexTrustReminder(): string { return 'Codex hooks written, but Codex may require you to review/trust them before they run — open /hooks or Settings → Hooks in Codex to trust them.'; } +/** + * Report what trusting the written Codex hooks did. `all` is for commands that + * write hooks on request (`init`, `hooks inject`); `problems` is for an + * interactive pull, which only speaks up when trusting failed or Codex keeps a + * project untrusted. + */ +export function reportCodexTrust(trust: CodexTrust | undefined, mode: 'all' | 'problems'): void { + if (!trust) return; + switch (trust.kind) { + case 'trusted': + if (trust.hooks > 0 && mode === 'all') log.success(`Trusted ${trust.hooks} teamai hook(s) in Codex`); + else log.debug(`Codex: trusted ${trust.hooks} teamai hook(s)`); + return; + case 'project-untrusted': + log.warn(`Codex marks ${trust.project} as untrusted, so it does not run the teamai hooks in ` + + `${path.join(trust.project, '.codex', 'hooks.json')}. teamai leaves that choice to you: ` + + 'trust the project in Codex to run them.'); + return; + case 'disabled': + case 'unavailable': + if (mode === 'all') log.warn(codexTrustReminder()); + else log.debug(`Codex hooks not trusted: ${trust.kind === 'disabled' ? 'codexTrustEnabled is false' : trust.reason}`); + return; + case 'failed': + log.warn(`Could not trust the teamai hooks in Codex (${trust.reason}). ${codexTrustReminder()}`); + return; + default: { + const unhandled: never = trust; + return unhandled; + } + } +} + /** Known teamai command substrings used to identify built-in / legacy hooks. */ const TEAMAI_COMMAND_MARKERS = [ 'teamai pull', 'teamai update', 'teamai track', 'teamai dashboard', 'teamai contribute-check', @@ -231,9 +276,18 @@ function teamDefsForTool(teamDefs: HookDef[], tool: string): HookDef[] { return teamDefs.filter((d) => !d.tools || d.tools.includes(tool)); } -/** Build the per-tool desired HookDef set: built-in (A) followed by team (B). */ -function desiredDefs(tool: string, teamDefs: HookDef[], builtinOverride?: BuiltinHookOverride): HookDef[] { - return [...applyBuiltinOverride(builtinHookDefs(tool), builtinOverride), ...teamDefsForTool(teamDefs, tool)]; +/** + * Build the per-tool desired HookDef set: built-in (A) followed by team (B). + * `teamOnly` leaves the built-ins out, for a file that holds team hooks only + * (the main checkout's, see MAIN_CHECKOUT_TEAM_HOOK_FILES). + */ +function desiredDefs( + tool: string, + teamDefs: HookDef[], + opts: { builtinOverride?: BuiltinHookOverride; teamOnly?: boolean }, +): HookDef[] { + const builtins = opts.teamOnly ? [] : applyBuiltinOverride(builtinHookDefs(tool), opts.builtinOverride); + return [...builtins, ...teamDefsForTool(teamDefs, tool)]; } // ─── Reconcile options & manifest ─────────────────────────── @@ -252,6 +306,13 @@ export interface ReconcileHooksOptions { builtinOverride?: BuiltinHookOverride; /** Project root used to gate non-self project-scope team hooks. */ teamHookProjectRoot?: string; + /** + * Write the team hooks alone: the built-ins are not desired here, and any + * built-in entry found in the file is removed. An existing entry running a + * desired team command counts as teamai's, so a copy an older layout left in + * the same file is replaced instead of duplicated. + */ + teamOnly?: boolean; } /** One injected team hook recorded in the manifest. */ @@ -377,6 +438,96 @@ function skipWhenAnotherHostLoadsClaudeSettings(command: string, tool: string): return `${CLAUDE_HOOK_OTHER_HOST_SKIP}${command}`; } +const POSIX_GATE_ROOT_RE = /^if \[ "\$PWD" = ('(?:[^']|'"'"')*') \] \|\| case "\$PWD" in /; + +/** + * The project root a POSIX gate was rendered for, or null for an ungated or + * cmd.exe-gated command. Only the POSIX form is read: it is the one the tools + * that moved to the main checkout (MAIN_CHECKOUT_TEAM_HOOK_FILES) ever wrote. + */ +function gatedProjectRoot(command: string): string | null { + const quoted = POSIX_GATE_ROOT_RE.exec(command)?.[1]; + return quoted ? quoted.slice(1, -1).split(`'"'"'`).join("'") : null; +} + +/** + * The project-scope team hook file, relative to the main checkout, of each + * tool that reads it from every linked worktree of the repository (#955): + * Claude Code reads `
/.claude/settings.local.json` in a worktree without + * a `.claude/` of its own, and Codex reads `
/.codex/hooks.json` (under + * the main checkout's trust) once the worktree has a `.codex/` directory, which + * its SessionStart creates (seedProjectAgentRoot). A non-self project scope + * writes these tools' team hooks there, ungated, so every checkout shares one + * set of entries and Codex one set of trust keys; their built-in hooks stay in + * HOME, which every worktree reads from its first session. Every other tool + * keeps its team hooks in HOME behind a `$PWD` gate. + */ +const MAIN_CHECKOUT_TEAM_HOOK_FILES: Readonly> = { + claude: '.claude/settings.local.json', + codex: '.codex/hooks.json', +}; + +/** Where a non-self project scope keeps the team hooks of MAIN_CHECKOUT_TEAM_HOOK_FILES' tools. */ +export interface MainCheckoutHooks { + /** The main checkout (realpath), shared by every linked worktree. */ + root: string; + /** Manifest of the team hooks written there, in the project's shared data home. */ + manifestPath: string; +} + +/** + * The main checkout a non-self project scope writes its Claude and Codex team + * hooks into, or null when its hooks do not go there: user scope, self mode + * (which writes every hook into its own checkout), or a project rooted at HOME, + * whose "main checkout" files are the HOME files themselves. + */ +export async function resolveMainCheckoutHooks(localConfig: LocalConfig): Promise { + if (localConfig.scope !== 'project' || !localConfig.projectRoot || isSelfMode(localConfig)) return null; + const root = await mainCheckoutOf(localConfig.projectRoot); + if (root === canonicalProjectRoot(getUserHome())) return null; + return { root, manifestPath: path.join(getDataHome(localConfig), 'managed-main-checkout-hooks.json') }; +} + +/** + * The main checkout (realpath) of the repository `projectRoot` belongs to, or + * `projectRoot` itself outside git — including a checkout that no longer + * exists, which git cannot be asked about. + */ +async function mainCheckoutOf(projectRoot: string): Promise { + const anchors = await pathExists(projectRoot) ? await resolveAnchors(projectRoot) : null; + return anchors?.projectAnchor ?? canonicalProjectRoot(projectRoot); +} + +/** The main checkout's team hook file of `tool`, when the tool keeps one there. */ +export function mainCheckoutHookFile(mainCheckout: MainCheckoutHooks | null | undefined, tool: string): string | null { + const relative = MAIN_CHECKOUT_TEAM_HOOK_FILES[tool]; + return mainCheckout && relative ? path.join(mainCheckout.root, relative) : null; +} + +/** + * The project roots whose gated team entries a pass over HOME removes for + * `tool`: this checkout's, and the ones an older CLI wrote for the main + * checkout, a live worktree of it, or a directory that no longer exists (a + * removed worktree). Another project's live entries are left alone. + */ +async function staleCheckoutGateRoots( + manifestPath: string | undefined, + tool: string, + current: string, + mainCheckout: MainCheckoutHooks, +): Promise { + const roots = new Set([canonicalProjectRoot(current)]); + if (!manifestPath) return [...roots]; + const records = (await readManifest(manifestPath))[tool] ?? []; + const gated = [...new Set(records.map((r) => gatedProjectRoot(r.command)).filter((r): r is string => r !== null))]; + if (gated.length === 0) return [...roots]; + const ours = new Set([mainCheckout.root, ...await listWorktrees(mainCheckout.root)]); + for (const root of gated) { + if (ours.has(root) || !await pathExists(root)) roots.add(root); + } + return [...roots]; +} + function scopedTeamDefs(teamDefs: HookDef[], projectRoot: string | undefined, tool: string): HookDef[] { const prepared = teamDefs.map((def) => ({ ...def, @@ -633,7 +784,7 @@ async function reconcileClaudeFormat( } } - const defs = opts.removeAll ? [] : desiredDefs(tool, teamDefs, opts.builtinOverride); + const defs = opts.removeAll ? [] : desiredDefs(tool, teamDefs, opts); const eventOrder = desiredEventOrder(defs, (e) => e); const events = [...eventOrder, ...Object.keys(settings.hooks).filter((e) => !eventOrder.includes(e))]; @@ -674,7 +825,7 @@ async function reconcileCursorFormat( const isManaged = (entry: CursorHookEntry): boolean => isTeamaiHookCommand(entry.command) || priorTeamCommands.has(entry.command); - const defs = opts.removeAll ? [] : desiredDefs(tool, teamDefs, opts.builtinOverride); + const defs = opts.removeAll ? [] : desiredDefs(tool, teamDefs, opts); const desiredByEvent: Record = {}; for (const def of defs) { const cursorEvent = CLAUDE_TO_CURSOR_EVENTS[def.event]; @@ -754,7 +905,7 @@ async function reconcileCopilotFormat( const isManaged = (entry: CopilotHookEntry): boolean => copilotEntryCommands(entry).some((command) => TEAMAI_COMMAND_MARKERS.some((marker) => command.includes(marker)) || priorTeamCommands.has(command), ); - const defs = opts.removeAll ? [] : desiredDefs(tool, teamDefs, opts.builtinOverride); + const defs = opts.removeAll ? [] : desiredDefs(tool, teamDefs, opts); const desiredByEvent: Record = {}; for (const def of defs) { const event = CLAUDE_TO_COPILOT_EVENTS[def.event]; @@ -811,7 +962,7 @@ async function reconcileCodexFormat( return TEAMAI_COMMAND_MARKERS.some((marker) => cmd.includes(marker)) || priorTeamCommands.has(cmd); }; - const defs = opts.removeAll ? [] : desiredDefs(tool, teamDefs, opts.builtinOverride); + const defs = opts.removeAll ? [] : desiredDefs(tool, teamDefs, opts); const eventOrder = desiredEventOrder(defs, (e) => e); const events = [...eventOrder, ...Object.keys(hooksJson.hooks).filter((e) => !eventOrder.includes(e))]; @@ -908,7 +1059,7 @@ async function reconcileZcodeFormat( return TEAMAI_COMMAND_MARKERS.some((marker) => cmd.includes(marker)) || priorTeamCommands.has(cmd); }; - const defs = opts.removeAll ? [] : desiredDefs(tool, teamDefs, opts.builtinOverride); + const defs = opts.removeAll ? [] : desiredDefs(tool, teamDefs, opts); const eventOrder = desiredEventOrder(defs, (e) => e); const eventsMap = cfg.hooks.events; const events = [...eventOrder, ...Object.keys(eventsMap).filter((e) => !eventOrder.includes(e))]; @@ -1121,9 +1272,12 @@ export async function reconcileHooks( const priorRecords = opts.teamHookProjectRoot ? allPriorRecords.filter((r) => isGatedForProject(r.command, opts.teamHookProjectRoot!)) : allPriorRecords; - const priorTeamCommands = new Set(priorRecords.map((r) => r.command)); const scopedDefs = scopedTeamDefs(teamDefs, opts.teamHookProjectRoot, tool); const desiredTeamCommands = new Set(scopedDefs.filter((d) => !d.tools || d.tools.includes(tool)).map((d) => d.command)); + const priorTeamCommands = new Set([ + ...priorRecords.map((r) => r.command), + ...(opts.teamOnly ? desiredTeamCommands : []), + ]); const format = detectFormat(tool); if (format === 'cursor') { @@ -1568,7 +1722,7 @@ export async function reconcileHooksToAllTools( baseDir: string, teamDefs: HookDef[], manifestPath: string, - opts: { removeAll?: boolean; builtinOverride?: BuiltinHookOverride; filterAgents?: string[]; settingsOnly?: boolean; installedBaseDir?: string; teamHookProjectRoot?: string; scope?: Scope; builtinsOnly?: BuiltinsOnly } = {}, + opts: { removeAll?: boolean; builtinOverride?: BuiltinHookOverride; filterAgents?: string[]; settingsOnly?: boolean; installedBaseDir?: string; teamHookProjectRoot?: string; scope?: Scope; builtinsOnly?: BuiltinsOnly; mainCheckout?: MainCheckoutHooks | null } = {}, ): Promise { // Without the manifest, reconcileHooks manages the built-in entries only. const teamManifestPath = opts.builtinsOnly ? undefined : manifestPath; @@ -1721,12 +1875,32 @@ export async function reconcileHooksToAllTools( claimedSettingsFiles.add(settingsFileKey); try { if (await skipInstalled(settingsPath, tool)) continue; - await reconcileHooks(settingsPath, tool, defs, { - manifestPath: teamManifestPath, - removeAll: opts.removeAll, - builtinOverride: opts.builtinOverride, - teamHookProjectRoot: opts.teamHookProjectRoot, - }); + const mainFile = mainCheckoutHookFile(opts.mainCheckout, tool); + if (mainFile && opts.mainCheckout && opts.teamHookProjectRoot && !opts.removeAll) { + // HOME keeps this tool's built-ins only: the project's team hooks live + // in the main checkout, and the gated copies of them are removed. + for (const root of await staleCheckoutGateRoots(teamManifestPath, tool, opts.teamHookProjectRoot, opts.mainCheckout)) { + await reconcileHooks(settingsPath, tool, [], { + manifestPath: teamManifestPath, + builtinOverride: opts.builtinOverride, + teamHookProjectRoot: root, + }); + } + } else { + await reconcileHooks(settingsPath, tool, defs, { + manifestPath: teamManifestPath, + removeAll: opts.removeAll, + builtinOverride: opts.builtinOverride, + teamHookProjectRoot: opts.teamHookProjectRoot, + }); + } + // With the team hooks unresolved, the ones installed there are kept. + if (mainFile && opts.mainCheckout && !opts.builtinsOnly) { + await reconcileMainCheckoutTeamHooks(mainFile, tool, defs, { + manifestPath: opts.mainCheckout.manifestPath, + removeAll: opts.removeAll, + }); + } } catch (e) { log.warn(`Failed to reconcile hooks for ${tool}: ${(e as Error).message}`); } @@ -1734,25 +1908,175 @@ export async function reconcileHooksToAllTools( } /** - * True if a trust-gated Codex tool (the public `codex`) is both configured with - * a settings path and actually installed on disk under baseDir. + * Reconcile the team hooks of one tool's main-checkout file + * (MAIN_CHECKOUT_TEAM_HOOK_FILES). Nothing is created for a tool without team + * hooks: a business repo that never had any gets no `.claude/settings.local.json` + * or `.codex/` from teamai. + */ +async function reconcileMainCheckoutTeamHooks( + file: string, + tool: string, + teamDefs: HookDef[], + opts: { manifestPath: string; removeAll?: boolean }, +): Promise { + const wanted = opts.removeAll ? [] : teamDefsForTool(teamDefs, tool); + if (wanted.length === 0 && !await pathExists(file)) return; + await reconcileHooks(file, tool, teamDefs, { manifestPath: opts.manifestPath, removeAll: opts.removeAll, teamOnly: true }); +} + +/** What a Codex trust pass for one scope works on. */ +interface CodexTrustTargets { + /** The Codex home teamai writes to (the member's `toolRoots.codex`, else `~/.codex`). */ + codexHome: string; + /** The checkout whose hook layers are listed (HOME in user scope). */ + cwd: string; + /** Every teamai hook in the Codex hook files of this scope, by file and command. */ + hooks: Array<{ file: string; command: string }>; + /** The main checkout, when Codex has to read its `.codex/` layer. */ + project?: string; + /** The files whose bytes a trust pass depends on. */ + inputs: string[]; + /** The Codex project MCP servers teamai wrote for this checkout. */ + mcpRecords: unknown[]; +} + +/** The teamai entries of one Codex hooks file: built-ins by marker, team hooks by manifest. */ +async function teamaiCodexHooks(file: string, manifestPath: string): Promise> { + const json = await readJson(file); + if (!json?.hooks) return []; + const teamCommands = new Set(((await readManifest(manifestPath))[CODEX_TOOL_ID] ?? []).map((r) => r.command)); + return Object.values(json.hooks) + .flatMap((groups) => (groups ?? []).flatMap((group) => group.hooks ?? [])) + .map((hook) => hook.command) + .filter((command) => typeof command === 'string' + && (TEAMAI_COMMAND_MARKERS.some((marker) => command.includes(marker)) || teamCommands.has(command))) + .map((command) => ({ file, command })); +} + +/** + * What a Codex trust pass covers in this scope, or null when teamai wrote + * nothing for the public Codex here (not a configured or enabled tool, no hook + * and no project MCP server written). The project is the main checkout: in self + * mode always, and in a non-self project scope when its `.codex/` holds team + * hooks or the checkout's Codex project MCP servers (#954), since Codex reads + * neither from an untrusted project. + */ +async function codexTrustTargets( + teamConfig: TeamaiConfig, + localConfig: LocalConfig, + selectedAgents?: string[], +): Promise { + const { baseDir, manifestPath, scope: hookScope } = resolveHookScope(localConfig); + const paths = scopedToolPaths(teamConfig, { ...localConfig, scope: hookScope })[CODEX_TOOL_ID]; + if (!paths?.settings) return null; + const agents = hookFilterAgents(teamConfig, localConfig, selectedAgents); + if (agents && !agents.includes(CODEX_TOOL_ID)) return null; + + const primary = path.join(canonicalProjectRoot(baseDir), paths.settings); + const sources = [{ file: primary, manifestPath }]; + let anchor: string | undefined; + let mainFile: string | null = null; + if (localConfig.scope === 'project' && localConfig.projectRoot) { + anchor = await mainCheckoutOf(localConfig.projectRoot); + const mainCheckout = await resolveMainCheckoutHooks(localConfig); + mainFile = mainCheckoutHookFile(mainCheckout, CODEX_TOOL_ID); + if (mainCheckout && mainFile) { + sources.push({ file: mainFile, manifestPath: mainCheckout.manifestPath }); + } else if (isSelfMode(localConfig) && path.join(anchor, paths.settings) !== primary) { + // A linked worktree of a self repo: Codex reads the main checkout's file. + sources.push({ file: path.join(anchor, paths.settings), manifestPath }); + } + } + const hooks = (await Promise.all(sources.map((s) => teamaiCodexHooks(s.file, s.manifestPath)))).flat(); + const mcpRecords = localConfig.scope === 'project' + ? (await readJson(expandHome(managedMcpManifestPath(getDataHome(localConfig), localConfig.projectRoot)))) + ?.[managedMcpManifestKey(CODEX_TOOL_ID, true)] ?? [] + : []; + if (hooks.length === 0 && mcpRecords.length === 0) return null; + + const projectLayer = isSelfMode(localConfig) + || hooks.some((h) => h.file === mainFile) + || mcpRecords.length > 0; + const codexHome = resolveToolRootDir(CODEX_TOOL_ID, DEFAULT_CODEX_ROOT, localConfig.toolRoots); + return { + codexHome, + cwd: localConfig.projectRoot ?? baseDir, + hooks, + ...(anchor && projectLayer ? { project: anchor } : {}), + inputs: [...sources.map((s) => s.file), path.join(codexHome, 'config.toml')], + mcpRecords, + }; +} + +/** + * Everything a trust pass's outcome depends on: what teamai wrote, the bytes of + * the hook files and of Codex's `config.toml`, and which `codex` binary would + * answer — an upgrade can change how Codex hashes a hook without touching + * any file here. + */ +async function codexTrustFingerprint(targets: CodexTrustTargets): Promise { + const hash = createHash('sha256').update(JSON.stringify({ ...targets, inputs: undefined })); + for (const file of targets.inputs) hash.update(`\0${file}\0${await readFileSafe(file) ?? ''}`); + const codex = findOnPath('codex'); + if (codex) { + try { + const real = realpathSync.native(codex); + const { mtimeMs, size } = await stat(real); + hash.update(`\0${real}\0${mtimeMs}\0${size}`); + } catch { + hash.update(`\0${codex}`); + } + } + return hash.digest('hex'); +} + +/** + * Trust in Codex what teamai wrote for it in this scope (#955): the hooks in + * its hook files and, when Codex has to read the main checkout's `.codex/`, + * that project. Run after every write of a Codex hooks file and, in a pull, + * after the MCP reconcile too, so a project whose only Codex content is its MCP + * servers is trusted by the same pull. * - * "Installed" uses the same root-directory gate as reconcileHooksToAllTools, so - * a true result means inject just wrote hooks that Codex may require the user to - * trust. Internal variants (codex-internal / tcodex) are excluded — they share - * the format but not the trust gate. Used to decide whether to print the - * reminder after inject. + * Skips the `codex app-server` spawn when the last pass ended `trusted` and + * none of its inputs changed since (see State.codexTrustFingerprint); `force` + * runs it anyway. Undefined when there is nothing of teamai's to trust. */ -export async function hasInstalledCodexTrustGatedTool( - toolPaths: Record, - baseDir: string, -): Promise { - for (const [tool, paths] of Object.entries(toolPaths)) { - if (!isCodexTrustGatedTool(tool) || !paths.settings) continue; - const toolRoot = path.join(baseDir, toolInstallRoot(paths.settings)); - if (await pathExists(toolRoot)) return true; +export async function trustCodexForScope( + teamConfig: TeamaiConfig, + localConfig: LocalConfig, + opts: { filterAgents?: string[]; force?: boolean } = {}, +): Promise { + const targets = await codexTrustTargets(teamConfig, localConfig, opts.filterAgents); + if (!targets) return undefined; + if (localConfig.codexTrustEnabled === false) return { kind: 'disabled' }; + const { loadStateForScope, saveStateForScope } = await import('./config.js'); + const cached = { kind: 'trusted', hooks: 0, ...(targets.project ? { project: targets.project } : {}) } as const; + if (!opts.force && (await loadStateForScope(localConfig)).codexTrustFingerprint === await codexTrustFingerprint(targets)) { + return cached; + } + const { trustCodexHooks } = await import('./codex-trust.js'); + const trust = await trustCodexHooks(targets); + const fingerprint = trust.kind === 'trusted' ? await codexTrustFingerprint(targets) : undefined; + const state = await loadStateForScope(localConfig); + if (state.codexTrustFingerprint !== fingerprint) { + state.codexTrustFingerprint = fingerprint; + await saveStateForScope(state, localConfig); } - return false; + return trust; +} + +/** + * Read-only, for `doctor`: which teamai hooks Codex will not run in this scope. + * Null when teamai wrote no Codex hook here. + */ +export async function readCodexHookTrustForScope( + teamConfig: TeamaiConfig, + localConfig: LocalConfig, +): Promise { + const targets = await codexTrustTargets(teamConfig, localConfig); + if (!targets || targets.hooks.length === 0) return null; + const { readCodexHookTrust } = await import('./codex-trust.js'); + return readCodexHookTrust(targets); } /** @@ -1780,7 +2104,15 @@ export async function sweepLegacyProjectHooks( ): Promise { const legacy = resolveLegacyProjectHookScope(localConfig); if (!legacy) return; - await reconcileHooksToAllTools(toolPaths, legacy.baseDir, [], legacy.manifestPath, { + // In the main checkout, Codex's legacy file is the one its team hooks now + // live in (MAIN_CHECKOUT_TEAM_HOOK_FILES); that pass removes the legacy + // built-ins from it, so the sweep must not empty it. + const mainCheckout = await resolveMainCheckoutHooks(localConfig); + const legacyToolPaths = Object.fromEntries(Object.entries(toolPaths).filter(([tool, paths]) => { + const mainFile = mainCheckoutHookFile(mainCheckout, tool); + return !(mainFile && paths.settings && path.join(canonicalProjectRoot(legacy.baseDir), paths.settings) === mainFile); + })); + await reconcileHooksToAllTools(legacyToolPaths, legacy.baseDir, [], legacy.manifestPath, { removeAll: true, settingsOnly: true, }); @@ -1813,6 +2145,23 @@ export type TeamHooksReconcile = | { ok: true; defs: HookDef[] } | { ok: false; builtins: BuiltinsOnly }; +/** + * The tools a hook pass reaches: the explicit selection (or the config's + * `enabledAgents` whitelist) minus `disabledAgents`, which always applies — + * with no whitelist, starting from the full configured tool set. Undefined + * means every tool. + */ +function hookFilterAgents( + teamConfig: TeamaiConfig, + localConfig: LocalConfig, + selected?: string[], +): string[] | undefined { + const filterAgents = selected ?? localConfig.enabledAgents; + const disabled = localConfig.disabledAgents; + if (!disabled || disabled.length === 0) return filterAgents; + return (filterAgents ?? Object.keys(teamConfig.toolPaths)).filter((t) => !disabled.includes(t)); +} + /** * Reconcile built-in (A) + team (B) hooks for a single scope's tools. * Resolves the scope's team hooks, the scope base dir + manifest, and @@ -1845,14 +2194,8 @@ export async function reconcileTeamHooksForConfig( : resolved.builtin.known ? resolved.builtin.override : undefined; const { baseDir, manifestPath, scope: hookScope } = resolveHookScope(localConfig); const explicitlySelectedAgents = opts.filterAgents ?? localConfig.enabledAgents; - let filterAgents = explicitlySelectedAgents; const disabled = localConfig.disabledAgents; - if (disabled && disabled.length > 0) { - // Exclusion always applies, even when there is no whitelist. When no - // whitelist exists, start from the full configured tool set. - const universe = filterAgents ?? Object.keys(teamConfig.toolPaths); - filterAgents = universe.filter((t) => !disabled.includes(t)); - } + const filterAgents = hookFilterAgents(teamConfig, localConfig, opts.filterAgents); // Resolve the tool paths at the scope hooks actually live in, not at the // config's scope: a non-self project scope puts hooks in HOME, so its paths // must be the user-scope ones. @@ -1883,6 +2226,7 @@ export async function reconcileTeamHooksForConfig( installedBaseDir: localConfig.scope === 'project' ? (localConfig.projectRoot ?? baseDir) : undefined, scope: localConfig.scope, builtinsOnly, + mainCheckout: await resolveMainCheckoutHooks(localConfig), }); const copilotExcluded = disabled?.includes(COPILOT_TOOL_ID) ?? false; diff --git a/src/init.ts b/src/init.ts index 26d9ba803..9e0a2c172 100644 --- a/src/init.ts +++ b/src/init.ts @@ -2,7 +2,7 @@ import YAML from 'yaml'; import fs from 'node:fs'; import path from 'node:path'; import { saveLocalConfig, loadTeamConfig, saveLocalConfigForScope, loadLocalConfigForScope, loadStateForScope, saveStateForScope, resolveProjectDataHome } from './config.js'; -import { describeUnappliedTeamHooks, hasTeamaiHooks, reconcileHooks, reconcileTeamHooksForConfig } from './hooks.js'; +import { describeUnappliedTeamHooks, hasTeamaiHooks, reconcileHooks, reconcileTeamHooksForConfig, reportCodexTrust, trustCodexForScope } from './hooks.js'; import { configureGitUser, initRepo, isGitRepo, getRemoteUrl, remotesMatch, redactGitCredentials, pullRepoFastForward } from './utils/git.js'; import { pushRepoDirectly } from './utils/git.js'; import { getProvider, detectProvider, detectProviderForInit, RepoNotFoundError, OrganizationNotFoundError, RepoCreatePermissionError } from './providers/index.js'; @@ -790,6 +790,7 @@ async function reconcileHooksForInit( ): Promise { const reconciled = await reconcileTeamHooksForConfig(teamConfig, localConfig, { filterAgents }); if (!reconciled.ok) log.warn(describeUnappliedTeamHooks(reconciled)); + reportCodexTrust(await trustCodexForScope(teamConfig, localConfig, { filterAgents, force: true }), 'all'); } /** diff --git a/src/pull.ts b/src/pull.ts index f3021a361..f098ed968 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -2332,6 +2332,12 @@ export async function pull( // hooks. User-scope MCP remains isolated in project mode. await reconcileMcpAllScopes(reconcileUser, reconcileProject, options, teamEnvs); + // 3.6a. Trust in Codex what the two stages above wrote for it: its hooks and, + // for a project, the main checkout whose `.codex/` holds team hooks or MCP + // servers (#955). After both, so a project whose only Codex content is its MCP + // servers is trusted by this same pull. + await trustCodexAllScopes(reconcileUser, reconcileProject, options); + // 3.6b. What the member should run for a team secret with no value (#875). // Not on the silent session-start pull: its output is discarded, and it runs // on every session. @@ -2614,6 +2620,27 @@ async function reconcileHooksAllScopes( * session start, so a change applied here takes effect in the user's next * session — which is exactly when the SessionStart pull hook runs. */ +async function trustCodexAllScopes( + userConfig: LocalConfig | null, + projectConfig: LocalConfig | null, + options: GlobalOptions, +): Promise { + if (options.dryRun) return; + const scopes = [userConfig, projectConfig].filter((c): c is LocalConfig => !!c); + for (const localConfig of scopes) { + try { + const teamConfig = await loadTeamConfig(localConfig.repo.localPath); + if (!teamConfig) continue; + const { reportCodexTrust, trustCodexForScope } = await import('./hooks.js'); + const trust = await trustCodexForScope(teamConfig, localConfig); + if (!options.silent) reportCodexTrust(trust, 'problems'); + else if (trust) log.debug(`[${localConfig.scope}] Codex trust: ${trust.kind}${'reason' in trust ? ` (${trust.reason})` : ''}`); + } catch (e) { + log.debug(`[${localConfig.scope}] Codex trust skipped: ${(e as Error).message}`); + } + } +} + async function reconcileMcpAllScopes( userConfig: LocalConfig | null, projectConfig: LocalConfig | null, diff --git a/src/types.ts b/src/types.ts index deab01b95..25be27119 100644 --- a/src/types.ts +++ b/src/types.ts @@ -615,6 +615,10 @@ export const LocalConfigSchema = z.object({ * takes precedence over the team `sharing.coAuthor` default. Undefined means * "defer to the team" (see resolveCoAuthor). */ coAuthorEnabled: z.boolean().optional(), + /** Per-machine opt-out of trusting in Codex what teamai writes for it: the + * hooks (`hooks.state`) and, for project hooks, the project (#955). + * Undefined means on. */ + codexTrustEnabled: z.boolean().optional(), /** When set, only inject hooks into these agents. Additive across multiple init --agent runs. */ enabledAgents: z.array(z.string()).optional(), /** @@ -825,6 +829,13 @@ export const StateSchema = z.object({ * literals stay valid; the reconciler treats absent as an empty map. */ coAuthorManaged: z.record(z.string(), z.boolean()).optional(), + /** + * Fingerprint of what the last Codex trust pass that ended `trusted` saw: + * the hooks teamai wrote for Codex, the Codex project MCP record, and the + * bytes of those hook files and of Codex's `config.toml` (#955). A pull whose + * inputs still match skips spawning `codex app-server`. Absent = run it. + */ + codexTrustFingerprint: z.string().optional(), lastUpdateCheck: z.string().nullable().default(null), availableUpdate: z.string().nullable().default(null), }); diff --git a/src/uninstall.ts b/src/uninstall.ts index 301fb70ac..55d9a1bbb 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -1,6 +1,6 @@ import path from 'node:path'; import { autoDetectInit, saveLocalConfig, saveLocalConfigForScope, UnreadableProjectConfigError } from './config.js'; -import { reconcileHooks, hasTeamaiHooks } from './hooks.js'; +import { reconcileHooks, hasTeamaiHooks, mainCheckoutHookFile, resolveMainCheckoutHooks } from './hooks.js'; import { removeOpenClawHooks, OPENCLAW_HOOK_DIR, @@ -288,6 +288,17 @@ function opencodePluginTargets(baseDir: string, scope: Scope): Array<{ baseDir: // ─── Discovery ───────────────────────────────────────── +/** + * A location teamai injected hooks into. `fileFor` names the file per tool for + * a location that holds only some tools' files (the main checkout's team hook + * files, #955); without it the tool's settings path is probed. + */ +interface HookTarget { + baseDir: string; + manifestPath: string; + fileFor?: (tool: string) => string | null; +} + async function discoverToolResources( tool: string, toolPath: TeamaiConfig['toolPaths'][string], @@ -297,7 +308,7 @@ async function discoverToolResources( teamSkillNames: Set, teamRuleNames: Set, teamAgentNames: Set, - hookTargets: Array<{ baseDir: string; manifestPath: string }>, + hookTargets: HookTarget[], standaloneHookManifestPath: string, scope: Scope, /** @@ -401,12 +412,12 @@ async function discoverToolResources( // from the same scope decision (`hookSettingsPath`), not from `toolPath` — // except for the legacy copy, written into by a CLI that knew // nothing about a member's relocated root, so it sits at the team path. - for (const { baseDir: hookBaseDir, manifestPath } of hookTargets) { + for (const { baseDir: hookBaseDir, manifestPath, fileFor } of hookTargets) { const settingsRel = path.resolve(hookBaseDir) === path.resolve(getUserHome()) ? (hookSettingsPath ?? toolPath.settings) : toolPath.settings; - const settingsPath = path.join(hookBaseDir, settingsRel); - if (await pathExists(settingsPath) + const settingsPath = fileFor ? fileFor(tool) : path.join(hookBaseDir, settingsRel); + if (settingsPath && await pathExists(settingsPath) && (await hasTeamaiHooks(settingsPath, tool, manifestPath) || isEmptyHooksResidue(await readJson>(settingsPath)))) { res.hookFiles.push({ path: settingsPath, tool, manifestPath }); @@ -566,9 +577,18 @@ async function buildRemovalPlan( // the SessionStart hook live in HOME forever. A legacy copy from // a pre-#370 CLI is swept too, tagged with its project manifest. const primaryHookScope = resolveHookScope(localConfig); - const hookTargets = [primaryHookScope]; + const hookTargets: HookTarget[] = [primaryHookScope]; const legacyHookScope = resolveLegacyProjectHookScope(localConfig); if (legacyHookScope) hookTargets.push(legacyHookScope); + // The project's Claude and Codex team hooks, in the main checkout (#955). + const mainCheckout = await resolveMainCheckoutHooks(localConfig); + if (mainCheckout) { + hookTargets.push({ + baseDir: mainCheckout.root, + manifestPath: mainCheckout.manifestPath, + fileFor: (tool) => mainCheckoutHookFile(mainCheckout, tool), + }); + } // Hook discovery resolves its file name at the same scope as the targets: a // non-self project scope discovers under HOME, so the tool paths there must be // the user-scope ones (previously the project-scope name was used, and a tool diff --git a/src/utils/lookpath.ts b/src/utils/lookpath.ts index 820ffe975..527f69143 100644 --- a/src/utils/lookpath.ts +++ b/src/utils/lookpath.ts @@ -82,15 +82,21 @@ export function pathDirs(options: LookPathOptions = {}): string[] { * runs. On win32, `uvx` also matches `uvx.exe` / `uvx.cmd` via PATHEXT. */ export function isOnPath(bin: string, options: LookPathOptions = {}): boolean { - if (!SAFE_BIN_RE.test(bin)) return false; + return findOnPath(bin, options) !== null; +} + +/** The first file on PATH that `bin` names (see isOnPath), or null. */ +export function findOnPath(bin: string, options: LookPathOptions = {}): string | null { + if (!SAFE_BIN_RE.test(bin)) return null; const platform = options.platform ?? process.platform; const names = candidateNames(bin, platform, options.pathExt ?? process.env.PATHEXT); const requireExecute = platform !== 'win32'; for (const dir of pathDirs(options)) { for (const name of names) { - if (isPresent(path.join(dir, name), requireExecute)) return true; + const candidate = path.join(dir, name); + if (isPresent(candidate, requireExecute)) return candidate; } } - return false; + return null; } From 6b2d1900fbbadddf5f15d678c563f1c60657fe2d Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 01:34:58 +0200 Subject: [PATCH 02/13] fix(hooks): trust main-checkout Codex hooks before worktree sessions --- docs/usage-guide.md | 4 +- docs/usage-guide.zh-CN.md | 4 +- skill-data/setup/SKILL.md | 5 +- skill-data/setup/references/setup-admin.md | 5 +- src/__tests__/bootstrap-self.test.ts | 39 +++++++++++++- src/__tests__/codex-trust.test.ts | 63 ++++++++++++++++++++++ src/__tests__/doctor.test.ts | 16 ++++++ src/__tests__/helpers/fake-codex.ts | 5 +- src/__tests__/init.test.ts | 7 +++ src/__tests__/pull-codex-trust.test.ts | 57 ++++++++++++++++++-- src/codex-trust.ts | 11 +++- src/doctor.ts | 4 +- src/hooks.ts | 12 +++-- src/pull.ts | 11 ++-- 14 files changed, 217 insertions(+), 26 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 05313f221..72f34484f 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -960,7 +960,7 @@ teamai push > Admins can set enforced rules in `teamai.yaml` (`sharing.rules.enforced`), which members cannot delete. -Most tools get one file per rule in their rules directory. Codex, `codex-internal` and `tcodex` read no rules directory (`.codex/rules/` holds Codex's own `*.rules` command policies), so `pull` writes no rule file for them. In user scope the team rules go into a `` block of the tool's own `AGENTS.md` (`~/.codex/AGENTS.md`, `~/.codex-internal/AGENTS.md`, `~/.tcodex/AGENTS.md`; a `toolRoots` entry moves it), which only that tool reads. In a project their session-start hook adds the project's team rules to each session instead: the project `AGENTS.md` is the owners' file, and other tools with a rules format of their own read it too. Hermes gets the same text in its `SOUL.md` block. Frontmatter is dropped, so a rule with `paths:` applies everywhere there, led by an `Applies to files matching: ` line. Codex runs the hook again after a compaction or a clear, and adds nothing when it resumes a session, which already holds the rules. A subagent Codex spawns gets them through the `SubagentStart` hook. The public Codex runs a new or changed hook only after you approve it in `/hooks`, and until then it gets no project rules. +Most tools get one file per rule in their rules directory. Codex, `codex-internal` and `tcodex` read no rules directory (`.codex/rules/` holds Codex's own `*.rules` command policies), so `pull` writes no rule file for them. In user scope the team rules go into a `` block of the tool's own `AGENTS.md` (`~/.codex/AGENTS.md`, `~/.codex-internal/AGENTS.md`, `~/.tcodex/AGENTS.md`; a `toolRoots` entry moves it), which only that tool reads. In a project their session-start hook adds the project's team rules to each session instead: the project `AGENTS.md` is the owners' file, and other tools with a rules format of their own read it too. Hermes gets the same text in its `SOUL.md` block. Frontmatter is dropped, so a rule with `paths:` applies everywhere there, led by an `Applies to files matching: ` line. Codex runs the hook again after a compaction or a clear, and adds nothing when it resumes a session, which already holds the rules. A subagent Codex spawns gets them through the `SubagentStart` hook. The public Codex runs only trusted hooks. teamai trusts the hooks it writes automatically; if automatic trust is disabled or fails, approve them in `/hooks` to receive the project rules. The culture, shared-instructions and recall blocks follow the same split. In user scope they go to that same `AGENTS.md`, and your own content outside the markers is kept. In a project the session-start hook adds them with the rules, and `pull` leaves the project `AGENTS.md` unchanged. The hook leaves out a block already present in the active project instructions file. Codex reads `AGENTS.override.md` when it exists, otherwise `AGENTS.md`, so a block in a shadowed `AGENTS.md` still reaches Codex through the hook. @@ -1924,7 +1924,7 @@ On Windows, the built-in hook dispatch commands that shell out through bash (e.g Cursor also loads `~/.claude/settings.json`, and Copilot CLI loads a trusted project's `.claude/settings.json` (self mode writes hooks there; Copilot does not load `~/.claude/settings.json`). `hook-dispatch --tool claude` exits only when that other host's own teamai hooks are on disk: `~/.cursor/hooks.json` or `$CURSOR_PROJECT_DIR/.cursor/hooks.json` contains `--tool cursor`, or `$COPILOT_PROJECT_DIR/.github/hooks/teamai.json` contains `--tool copilot`. Team hook commands written for `claude` use the same check. A setup with only Claude keeps running inside Cursor, because there is no second copy. `COPILOT_CLI` is not a signal: Copilot sets it on every subprocess, including a Claude session started from its shell. Claude Code sets neither `CURSOR_VERSION` nor `COPILOT_PROJECT_DIR`. Run `teamai pull` or `teamai hooks inject` again so an already installed team hook picks up the guard. -> **Codex hook trust** — Codex (the OpenAI / ChatGPT Codex app, tool id `codex`) runs a non-managed hook only once it is trusted, and skips an untrusted or changed one without a word; it reads a project's `.codex/` only when the project is trusted. So after every write of a Codex hooks file (`init`, every `pull` including the session-start one, `teamai hooks inject`) teamai trusts exactly the hooks it wrote, through `codex app-server` — the same call Codex's `/hooks` trust prompt makes. Your own hooks in the same file are left alone. In a project, teamai also trusts the main checkout when Codex has to read teamai's hooks or MCP servers from its `.codex/`; a project you marked untrusted in Codex stays so, and teamai says so. Trust written by a session-start pull applies from the next Codex session: the running one already loaded its hooks. A linked worktree reads the main checkout's `.codex/hooks.json` only once it has a `.codex/` directory, which the Codex session-start hook creates, so a new worktree gets the team hooks from its second Codex session; the built-in hooks live in `~/.codex/hooks.json` and run from the first. To trust them yourself, set `codexTrustEnabled: false` in `config.yaml`. Without `codex` on PATH, or when the app-server fails, teamai prints a reminder to trust them in `/hooks` or Settings → Hooks instead. `teamai doctor` asks Codex which teamai hooks it will not run and names them. +> **Codex hook trust** — Codex (the OpenAI / ChatGPT Codex app, tool id `codex`) runs a non-managed hook only once it is trusted, and skips an untrusted or changed one without a word; it reads a project's `.codex/` only when the project is trusted. So after every write of a Codex hooks file (`init`, every `pull` including the session-start one, `teamai hooks inject`) teamai trusts exactly the hooks it wrote, through `codex app-server` — the same call Codex's `/hooks` trust prompt makes. Your own hooks in the same file are left alone. In a project, teamai also trusts the main checkout when Codex has to read teamai's hooks or MCP servers from its `.codex/`; a project you marked untrusted in Codex stays so, and teamai says so. Trust written by a session-start pull applies from the next Codex session: the running one already loaded its hooks. A linked worktree reads the main checkout's `.codex/hooks.json` only once it has a `.codex/` directory, which the Codex session-start hook creates, so a new worktree gets the team hooks from its second Codex session; the built-in hooks live in `~/.codex/hooks.json` and run from the first. To trust them yourself, set `codexTrustEnabled: false` in `config.yaml`. `init` and `hooks inject` print a reminder to trust them in `/hooks` or Settings → Hooks when `codex` is absent or its app-server fails. An interactive pull warns on app-server failure and stays quiet when `codex` is absent; silent pulls record the result in the debug log. `teamai doctor` asks Codex which teamai hooks it will not run and names them. ### Team Hooks Declaration diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 9c3fda64b..9cf570c2e 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -881,7 +881,7 @@ teamai push > 管理员可在 `teamai.yaml` 中设置强制规则(`sharing.rules.enforced`),成员不可删除。 -大多数工具在自己的 rules 目录中为每条 rule 得到一个文件。Codex、`codex-internal` 和 `tcodex` 不读取 rules 目录(`.codex/rules/` 存放的是 Codex 自己的 `*.rules` 命令策略文件),因此 `pull` 不为它们写任何 rule 文件。user scope 下,团队 rule 写入该工具自己的 `AGENTS.md`(`~/.codex/AGENTS.md`、`~/.codex-internal/AGENTS.md`、`~/.tcodex/AGENTS.md`;`toolRoots` 条目可改变其位置)中的 `` 区块,只有该工具读取这个文件。在项目中,改由它们的 session-start hook 把项目的团队 rule 加入每个会话:项目 `AGENTS.md` 属于项目维护者,其他拥有自己 rules 格式的工具也会读取它。Hermes 的 `SOUL.md` 区块得到同样的内容。frontmatter 会被去掉,所以带 `paths:` 的 rule 在这里对所有文件生效,并以一行 `Applies to files matching: ` 开头。Codex 在压缩上下文或 clear 之后会再次运行该 hook;恢复会话时不添加任何内容,因为会话中已包含这些 rule。Codex 启动的子 agent 通过 `SubagentStart` hook 获得它们。公开版 Codex 只在你于 `/hooks` 中批准新增或改动的 hook 后才运行它,在此之前不会得到项目的 rule。 +大多数工具在自己的 rules 目录中为每条 rule 得到一个文件。Codex、`codex-internal` 和 `tcodex` 不读取 rules 目录(`.codex/rules/` 存放的是 Codex 自己的 `*.rules` 命令策略文件),因此 `pull` 不为它们写任何 rule 文件。user scope 下,团队 rule 写入该工具自己的 `AGENTS.md`(`~/.codex/AGENTS.md`、`~/.codex-internal/AGENTS.md`、`~/.tcodex/AGENTS.md`;`toolRoots` 条目可改变其位置)中的 `` 区块,只有该工具读取这个文件。在项目中,改由它们的 session-start hook 把项目的团队 rule 加入每个会话:项目 `AGENTS.md` 属于项目维护者,其他拥有自己 rules 格式的工具也会读取它。Hermes 的 `SOUL.md` 区块得到同样的内容。frontmatter 会被去掉,所以带 `paths:` 的 rule 在这里对所有文件生效,并以一行 `Applies to files matching: ` 开头。Codex 在压缩上下文或 clear 之后会再次运行该 hook;恢复会话时不添加任何内容,因为会话中已包含这些 rule。Codex 启动的子 agent 通过 `SubagentStart` hook 获得它们。公开版 Codex 只运行已信任的 hook。teamai 会自动信任它写入的 hooks;如果自动信任被禁用或失败,请在 `/hooks` 中批准它们以获得项目的 rule。 culture、共享指令和 recall 区块采用同样的划分。user scope 下它们写入同一个 `AGENTS.md`,标记之外你自己的内容保持不变。在项目中,session-start hook 把它们与 rule 一起加入会话,`pull` 不改动项目 `AGENTS.md`。hook 会省略项目当前生效的指令文件中已有的区块。Codex 在 `AGENTS.override.md` 存在时读取它,否则读取 `AGENTS.md`,因此被遮蔽的 `AGENTS.md` 中的区块仍会通过 hook 送达。 @@ -1789,7 +1789,7 @@ inject 和 remove 只会操作你实际已安装的工具(即 `~/./` 根 Cursor 也会加载 `~/.claude/settings.json`。Copilot CLI 会加载受信任项目里的 `.claude/settings.json`(self mode 把 hook 写在项目里;Copilot 不加载 `~/.claude/settings.json`)。只有另一边的 teamai hook 已经在磁盘上时,`hook-dispatch --tool claude` 才会退出:`~/.cursor/hooks.json` 或 `$CURSOR_PROJECT_DIR/.cursor/hooks.json` 含有 `--tool cursor`,或 `$COPILOT_PROJECT_DIR/.github/hooks/teamai.json` 含有 `--tool copilot`。写给 `claude` 的团队 hook 命令用同一判断。只启用了 Claude 时,Cursor 里这份 hook 照常运行,因为没有第二份可以接替。`COPILOT_CLI` 不能当信号:Copilot 会给每个子进程设置它,包括从它的 shell 里启动的 Claude。Claude Code 不会设置 `CURSOR_VERSION` 或 `COPILOT_PROJECT_DIR`。已经装好的团队 hook 需要再跑一次 `teamai pull` 或 `teamai hooks inject`,才会带上这个判断。 -> **Codex hook 信任** — Codex(OpenAI / ChatGPT Codex 应用,工具 id 为 `codex`)只运行已信任的非托管 hook,未信任或已变更的 hook 会被静默跳过;且只有项目被信任时才读取其 `.codex/`。因此每次写入 Codex hooks 文件后(`init`、每次 `pull`(含 SessionStart 触发的 pull)、`teamai hooks inject`),teamai 都会通过 `codex app-server` 信任它写入的那些 hook——与 Codex `/hooks` 信任提示调用的是同一接口。同一文件里你自己的 hook 不受影响。在项目中,当 Codex 需要从主 checkout 的 `.codex/` 读取 teamai 的 hooks 或 MCP servers 时,teamai 也会信任该主 checkout;你在 Codex 中标记为不信任的项目保持不变,teamai 会提示。SessionStart 触发的 pull 写入的信任从下一个 Codex 会话起生效:当前会话已加载了它的 hooks。linked worktree 只有在存在 `.codex/` 目录时才读取主 checkout 的 `.codex/hooks.json`,该目录由 Codex 的 SessionStart hook 创建,因此新 worktree 从第二个 Codex 会话起获得团队 hooks;内置 hooks 位于 `~/.codex/hooks.json`,从第一个会话起就运行。若要自行信任,在 `config.yaml` 中设置 `codexTrustEnabled: false`。PATH 中没有 `codex`、或 app-server 失败时,teamai 改为输出提示,请你在 `/hooks` 或 Settings → Hooks 中信任。`teamai doctor` 会向 Codex 查询哪些 teamai hooks 不会运行并逐一列出。 +> **Codex hook 信任** — Codex(OpenAI / ChatGPT Codex 应用,工具 id 为 `codex`)只运行已信任的非托管 hook,未信任或已变更的 hook 会被静默跳过;且只有项目被信任时才读取其 `.codex/`。因此每次写入 Codex hooks 文件后(`init`、每次 `pull`(含 SessionStart 触发的 pull)、`teamai hooks inject`),teamai 都会通过 `codex app-server` 信任它写入的那些 hook——与 Codex `/hooks` 信任提示调用的是同一接口。同一文件里你自己的 hook 不受影响。在项目中,当 Codex 需要从主 checkout 的 `.codex/` 读取 teamai 的 hooks 或 MCP servers 时,teamai 也会信任该主 checkout;你在 Codex 中标记为不信任的项目保持不变,teamai 会提示。SessionStart 触发的 pull 写入的信任从下一个 Codex 会话起生效:当前会话已加载了它的 hooks。linked worktree 只有在存在 `.codex/` 目录时才读取主 checkout 的 `.codex/hooks.json`,该目录由 Codex 的 SessionStart hook 创建,因此新 worktree 从第二个 Codex 会话起获得团队 hooks;内置 hooks 位于 `~/.codex/hooks.json`,从第一个会话起就运行。若要自行信任,在 `config.yaml` 中设置 `codexTrustEnabled: false`。PATH 中没有 `codex` 或 app-server 失败时,`init` 和 `hooks inject` 会提示你在 `/hooks` 或 Settings → Hooks 中信任。交互式 pull 仅在 app-server 失败时警告,缺少 `codex` 时保持静默;silent pull 将结果记录在 debug 日志中。`teamai doctor` 会向 Codex 查询哪些 teamai hooks 不会运行并逐一列出。 ### 团队 Hooks 声明 diff --git a/skill-data/setup/SKILL.md b/skill-data/setup/SKILL.md index 0ab1c9129..fd80fc460 100644 --- a/skill-data/setup/SKILL.md +++ b/skill-data/setup/SKILL.md @@ -46,8 +46,9 @@ and create-repo URLs, and the per-provider caveats, and points at install. Let `teamai init` set up every AI tool already installed (omitting `--agent` gives an interactive picker; select all detected tools). **After init, report which agents were set up** — in the user's language, which tools now - auto-start TeamAI, and which detected tools were skipped and why (e.g. Codex - hooks not trusted, CodeBuddy design). Verify the real per-tool result with + auto-start TeamAI, which detected tools were skipped and why (e.g. CodeBuddy + design), and any installed hooks that still need trust (e.g. Codex with + automatic trust disabled or unavailable). Verify the real per-tool result with `teamai doctor` and `teamai hooks list`. 3. **After init, resources appear on the NEXT session.** `teamai init` injects a session-start hook that auto-runs `teamai pull`. Empty skills/rules directories diff --git a/skill-data/setup/references/setup-admin.md b/skill-data/setup/references/setup-admin.md index c10901940..74a50157a 100644 --- a/skill-data/setup/references/setup-admin.md +++ b/skill-data/setup/references/setup-admin.md @@ -213,8 +213,9 @@ login` run in an interactive shell (see Step 3). Claude Code"). Omitting `--agent` gives an interactive picker — select **every AI tool already installed** on the machine. Then **report back which agents were set up**, in the user's language: name the tools that will now auto-start TeamAI, and -any detected tool that was skipped and why (e.g. Codex hooks not trusted, -CodeBuddy/WorkBuddy by design — see the troubleshooting reference, `"$(teamai skill path core)/references/troubleshooting.md"`). +any detected tool that was skipped and why (e.g. CodeBuddy/WorkBuddy by design), +and any installed hooks that still need trust (e.g. Codex with automatic trust +disabled or unavailable). See the troubleshooting reference, `"$(teamai skill path core)/references/troubleshooting.md"`. ## Step 6 — Verify with doctor diff --git a/src/__tests__/bootstrap-self.test.ts b/src/__tests__/bootstrap-self.test.ts index d21f2ebf8..d2628197b 100644 --- a/src/__tests__/bootstrap-self.test.ts +++ b/src/__tests__/bootstrap-self.test.ts @@ -1,8 +1,18 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import YAML from 'yaml'; +vi.mock('../providers/index.js', async (importOriginal) => ({ + ...await importOriginal(), + getProvider: vi.fn(() => ({ + isAuthenticated: () => true, + authenticate: async () => 'tester', + parseRepoInput: (remote: string) => ({ httpsUrl: remote }), + })), +})); + +import { installFakeCodex, readFakeCodexState } from './helpers/fake-codex.js'; import { bootstrapSelfRepo } from '../bootstrap.js'; import { detectProjectConfig } from '../config.js'; @@ -13,6 +23,7 @@ beforeEach(() => { }); afterEach(() => { + vi.unstubAllEnvs(); fs.rmSync(tmpDir, { recursive: true, force: true }); }); @@ -33,6 +44,32 @@ describe('bootstrapSelfRepo', () => { expect(result).toBe('skip'); }); + it('trusts self project hooks during a silent bootstrap', async () => { + tmpDir = fs.realpathSync.native(tmpDir); + const home = path.join(tmpDir, 'home'); + const project = path.join(tmpDir, 'project'); + const teamaiDir = path.join(project, '.teamai'); + const codexHome = path.join(home, '.codex'); + fs.mkdirSync(teamaiDir, { recursive: true }); + fs.mkdirSync(codexHome, { recursive: true }); + const fakeBin = installFakeCodex(); + vi.stubEnv('HOME', home); + vi.stubEnv('PATH', `${fakeBin}${path.delimiter}${process.env.PATH ?? ''}`); + fs.writeFileSync(path.join(teamaiDir, 'teamai.yaml'), YAML.stringify({ + team: 'test', mode: 'self', repo: 'https://github.com/acme/app.git', provider: 'github', + toolPaths: { codex: { skills: '.codex/skills', settings: '.codex/hooks.json' } }, + })); + try { + expect(await bootstrapSelfRepo(project, { silent: true })).toBe('bootstrapped'); + const state = readFakeCodexState(codexHome); + expect(state.projects[project]).toEqual({ trust_level: 'trusted' }); + expect(Object.keys(state.hooksState).length).toBeGreaterThan(0); + expect(Object.keys(state.hooksState).every((key) => key.startsWith(path.join(project, '.codex', 'hooks.json')))).toBe(true); + } finally { + fs.rmSync(fakeBin, { recursive: true, force: true }); + } + }); + it("returns 'already' when a local config.yaml is already present", async () => { const teamaiDir = path.join(tmpDir, '.teamai'); fs.mkdirSync(teamaiDir, { recursive: true }); diff --git a/src/__tests__/codex-trust.test.ts b/src/__tests__/codex-trust.test.ts index dc0dd2eaf..ccb41bf81 100644 --- a/src/__tests__/codex-trust.test.ts +++ b/src/__tests__/codex-trust.test.ts @@ -9,6 +9,7 @@ vi.mock('../utils/logger.js', () => ({ })); import { readCodexHookTrustForScope, reconcileTeamHooksForConfig, reportCodexTrust, trustCodexForScope } from '../hooks.js'; +import { trustCodexHooks, trustCodexProject } from '../codex-trust.js'; import { log } from '../utils/logger.js'; import type { LocalConfig, TeamaiConfig } from '../types.js'; import { installFakeCodex, readFakeCodexState, writeFakeCodexOptions } from './helpers/fake-codex.js'; @@ -109,6 +110,25 @@ describe('Codex hook trust — user scope', () => { expect(reconciled.codexTrust).toEqual({ kind: 'trusted', hooks: teamai.length }); }); + it('does not trust a member command containing a teamai marker', async () => { + await writeAndTrust(userConfig()); + const file = path.join(codexHome(), 'hooks.json'); + const json = await fse.readJson(file); + json.hooks.Stop.push({ hooks: [{ type: 'command', command: 'teamai pull --silent && my-script' }] }); + await fse.writeJson(file, json); + + await trustCodexForScope(teamConfig, userConfig()); + + const member = (await codexEntries(file)).find((e) => e.command === 'teamai pull --silent && my-script')!; + expect(trustedKeys()).not.toContain(member.key); + }); + + it('fails actionably when Codex does not list a requested hook', async () => { + const file = path.join(codexHome(), 'hooks.json'); + const result = await trustCodexHooks({ codexHome: codexHome(), cwd: home, hooks: [{ file, command: 'missing' }] }); + expect(result).toEqual({ kind: 'failed', reason: expect.stringMatching(/hooks\/list.*missing.*not loaded.*teamai pull/) }); + }); + it('writes nothing to Codex when every teamai hook is already trusted', async () => { await writeYaml(LINT_HOOK); await writeAndTrust(userConfig()); @@ -261,6 +281,16 @@ describe('Codex trust — skipping a pass that has nothing new', () => { expect(calls('initialize')).toBe(2); }); + it('does not cache a pass with a requested hook missing from hooks/list', async () => { + await writeYaml(LINT_HOOK); + writeFakeCodexOptions(codexHome(), { omitCommands: ['npm run lint'] }); + expect((await writeAndTrust(userConfig())).codexTrust?.kind).toBe('failed'); + expect((await writeAndTrust(userConfig())).codexTrust?.kind).toBe('failed'); + expect(calls('initialize')).toBe(2); + writeFakeCodexOptions(codexHome(), {}); + expect((await writeAndTrust(userConfig())).codexTrust?.kind).toBe('trusted'); + }); + it('keeps asking while the last pass did not end trusted', async () => { writeFakeCodexOptions(codexHome(), { failMethod: 'hooks/list' }); await writeAndTrust(userConfig()); @@ -318,6 +348,17 @@ describe('Codex trust — project scopes', () => { } as Partial); } + it('trustCodexProject resolves symlinks and preserves an explicit untrusted choice', async () => { + const alias = path.join(home, 'project-link'); + await fse.ensureSymlink(project, alias, 'dir'); + expect(await trustCodexProject({ codexHome: codexHome(), project: alias })).toEqual({ kind: 'trusted', hooks: 0, project }); + await fse.outputJson(path.join(codexHome(), 'fake-state.json'), { + projects: { [project]: { trust_level: 'untrusted' } }, hooksState: {}, calls: [], + }); + expect(await trustCodexProject({ codexHome: codexHome(), project: alias })).toEqual({ kind: 'project-untrusted', hooks: 0, project }); + expect(calls('config/batchWrite')).toBe(0); + }); + it('self mode trusts the repo, then every teamai hook in its .codex/hooks.json', async () => { await fse.ensureDir(path.join(project, '.codex')); await fse.outputFile(path.join(project, '.teamai', 'hooks', 'hooks.yaml'), LINT_HOOK); @@ -360,6 +401,28 @@ describe('Codex trust — project scopes', () => { } }); + it('shares the trusted fingerprint when alternating project checkouts', async () => { + const git = (...args: string[]) => { + const result = spawnSync('git', args, { cwd: project, encoding: 'utf8' }); + if (result.status !== 0) throw new Error(result.stderr); + }; + git('init', '-q'); + git('-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-q', '--allow-empty', '-m', 'init'); + const worktree = `${project}-wt`; + git('worktree', 'add', '-q', '--detach', worktree); + try { + await writeYaml(LINT_HOOK); + const { resolveProjectDataHome } = await import('../config.js'); + const dataHome = await resolveProjectDataHome(project); + await writeAndTrust(projectConfig({ projectRoot: worktree, dataHome })); + await writeAndTrust(projectConfig({ dataHome })); + await writeAndTrust(projectConfig({ projectRoot: worktree, dataHome })); + expect(calls('initialize')).toBe(1); + } finally { + git('worktree', 'remove', '--force', worktree); + } + }); + it('trusts the main checkout and its team hooks in a project scope', async () => { await writeYaml(LINT_HOOK); diff --git a/src/__tests__/doctor.test.ts b/src/__tests__/doctor.test.ts index 73f93377c..b612697c4 100644 --- a/src/__tests__/doctor.test.ts +++ b/src/__tests__/doctor.test.ts @@ -469,6 +469,22 @@ describe('doctor — hook checks', () => { expect(fix).toContain('codexTrustEnabled'); }); + it('explains how to load main hooks in a new linked worktree', async () => { + mockedReadCodexHookTrust.mockResolvedValueOnce({ kind: 'listed', notTrusted: [ + { file: '/main/.codex/hooks.json', command: 'echo team', status: 'not loaded' }, + ] }); + await doctor({}); + const fix = consoleSpy.mock.calls.map((c) => String(c[0])).find((msg) => msg.includes('Codex will not run')); + expect(fix).toContain('create `.codex/` or start Codex there'); + expect(fix).toContain('then open a new session'); + }); + + it('includes the app-server failure cause in the note', async () => { + mockedReadCodexHookTrust.mockResolvedValueOnce({ kind: 'failed', reason: 'hooks/list: boom' }); + await doctor({}); + expect(mockedLog.info.mock.calls.some(([message]) => String(message).includes('hooks/list: boom'))).toBe(true); + }); + it('keeps the trust note when Codex cannot be asked', async () => { mockedReadCodexHookTrust.mockResolvedValueOnce({ kind: 'unavailable', reason: 'codex not found on PATH' }); await doctor({}); diff --git a/src/__tests__/helpers/fake-codex.ts b/src/__tests__/helpers/fake-codex.ts index c0e62171e..98ee2e22e 100644 --- a/src/__tests__/helpers/fake-codex.ts +++ b/src/__tests__/helpers/fake-codex.ts @@ -32,6 +32,7 @@ function layer(file, source, s, out) { const src = real(file); for (const [event, groups] of Object.entries(json.hooks)) { (groups || []).forEach((g, gi) => (g.hooks || []).forEach((h, hi) => { + if ((opts.omitCommands || []).includes(h.command)) return; const key = src + ':' + snake(event) + ':' + gi + ':' + hi; const currentHash = 'sha256:' + crypto.createHash('sha256').update(JSON.stringify(h)).digest('hex'); const trusted = s.hooksState[key] && s.hooksState[key].trusted_hash; @@ -45,7 +46,7 @@ function list(cwd, s) { layer(path.join(home, 'hooks.json'), 'user', s, out); const c = real(cwd); const mapped = (opts.projectLayers || {})[c]; - const projects = mapped ? [real(mapped)] : Object.keys(s.projects).filter((p) => c === p || c.startsWith(p + path.sep)); + const projects = mapped ? (fs.existsSync(path.join(c, '.codex')) ? [real(mapped)] : []) : Object.keys(s.projects).filter((p) => c === p || c.startsWith(p + path.sep)); for (const p of projects) { if ((s.projects[p] || {}).trust_level !== 'trusted') continue; layer(path.join(p, '.codex', 'hooks.json'), 'project', s, out); @@ -110,7 +111,7 @@ export function readFakeCodexState(codexHome: string): FakeCodexState { export function writeFakeCodexOptions( codexHome: string, - options: { failMethod?: string; exit?: boolean; projectLayers?: Record }, + options: { failMethod?: string; exit?: boolean; omitCommands?: string[]; projectLayers?: Record }, ): void { fs.mkdirSync(codexHome, { recursive: true }); fs.writeFileSync(path.join(codexHome, 'fake.json'), JSON.stringify(options)); diff --git a/src/__tests__/init.test.ts b/src/__tests__/init.test.ts index 0d39cb786..f54cd7fed 100644 --- a/src/__tests__/init.test.ts +++ b/src/__tests__/init.test.ts @@ -753,6 +753,13 @@ describe('init', () => { ); }); + it('trusts Codex after injecting hooks and forces the initialization pass', async () => { + const { reconcileTeamHooksForConfig, trustCodexForScope } = await import('../hooks.js'); + await initWithTeamConfig(); + expect(trustCodexForScope).toHaveBeenCalledWith(expect.anything(), expect.anything(), { filterAgents: undefined, force: true }); + expect(vi.mocked(reconcileTeamHooksForConfig).mock.invocationCallOrder[0]).toBeLessThan(vi.mocked(trustCodexForScope).mock.invocationCallOrder[0]); + }); + it('announces the stub as ready only when it landed', async () => { const { log } = await import('../utils/logger.js'); mockDeployBuiltinSkills.mockResolvedValueOnce(1); diff --git a/src/__tests__/pull-codex-trust.test.ts b/src/__tests__/pull-codex-trust.test.ts index a4ccc17ec..fe37f67df 100644 --- a/src/__tests__/pull-codex-trust.test.ts +++ b/src/__tests__/pull-codex-trust.test.ts @@ -11,6 +11,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import fse from 'fs-extra'; import os from 'node:os'; import path from 'node:path'; +import { execFileSync } from 'node:child_process'; vi.mock('../config.js', async (importOriginal) => ({ ...(await importOriginal()), @@ -88,13 +89,17 @@ import { pull } from '../pull.js'; import { reconcileMcpForConfig } from '../mcp-reconcile.js'; import { getDataHome, managedMcpManifestKey, managedMcpManifestPath } from '../types.js'; import type { LocalConfig, TeamaiConfig } from '../types.js'; -import { installFakeCodex, readFakeCodexState } from './helpers/fake-codex.js'; +import { readCodexHookTrustForScope } from '../hooks.js'; +import { log } from '../utils/logger.js'; +import { installFakeCodex, readFakeCodexState, writeFakeCodexOptions } from './helpers/fake-codex.js'; describe('pull trusts the Codex project after writing its MCP servers', () => { let tempDir: string; let homeDir: string; let project: string; let fakeBin: string; + let localConfig: LocalConfig; + let teamConfig: TeamaiConfig; beforeEach(async () => { tempDir = await fse.realpath(await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-pull-codex-trust-'))); @@ -109,7 +114,7 @@ describe('pull trusts the Codex project after writing its MCP servers', () => { await fse.ensureDir(path.join(repoPath, 'manifest')); await fse.writeFile(path.join(repoPath, 'manifest', 'roles.yaml'), 'version: 1\n'); - const localConfig = { + localConfig = { repo: { localPath: repoPath, remote: 'owner/repo' }, username: 'tester', scope: 'project', @@ -117,7 +122,7 @@ describe('pull trusts the Codex project after writing its MCP servers', () => { primaryRole: 'dev', additionalRoles: [], } as unknown as LocalConfig; - const teamConfig = { + teamConfig = { team: 'test', description: '', repo: 'owner/repo', @@ -151,4 +156,50 @@ describe('pull trusts the Codex project after writing its MCP servers', () => { expect(reconcileMcpForConfig).toHaveBeenCalled(); expect(readFakeCodexState(path.join(homeDir, '.codex')).projects[project]).toEqual({ trust_level: 'trusted' }); }); + it('pull from a worktree without .codex trusts the main keys before SessionStart', async () => { + const git = (...args: string[]) => execFileSync('git', args, { cwd: project }); + git('init', '-q'); + git('-c', 'user.name=test', '-c', 'user.email=test@test', 'commit', '-q', '--allow-empty', '-m', 'init'); + const worktree = path.join(tempDir, 'worktree'); + git('worktree', 'add', '-q', '--detach', worktree); + localConfig.projectRoot = worktree; + writeFakeCodexOptions(path.join(homeDir, '.codex'), { projectLayers: { [worktree]: project } }); + vi.mocked(reconcileMcpForConfig).mockResolvedValue({ changes: [], wrote: false } as never); + await fse.outputFile(path.join(localConfig.repo.localPath, 'hooks', 'hooks.yaml'), + 'hooks:\n - id: added\n description: new hook\n event: PreToolUse\n command: echo new-team-hook\n'); + + await pull({ force: true }); + + expect(await fse.pathExists(path.join(worktree, '.codex'))).toBe(false); + const state = readFakeCodexState(path.join(homeDir, '.codex')); + expect(Object.keys(state.hooksState).some((key) => key.startsWith(path.join(project, '.codex', 'hooks.json')))).toBe(true); + expect(state.calls.filter((c) => c.method === 'hooks/list').map((c) => c.params)).toEqual([{ cwds: [project] }]); + expect(await readCodexHookTrustForScope(teamConfig, localConfig)).toEqual({ + kind: 'listed', notTrusted: [{ file: path.join(project, '.codex', 'hooks.json'), command: 'echo new-team-hook', status: 'not loaded' }], + }); + await fse.ensureDir(path.join(worktree, '.codex')); + expect(await readCodexHookTrustForScope(teamConfig, localConfig)).toEqual({ kind: 'listed', notTrusted: [] }); + }); + + it('does not ask Codex on a dry run', async () => { + await pull({ force: true, dryRun: true }); + expect(readFakeCodexState(path.join(homeDir, '.codex')).calls).toEqual([]); + }); + + it.each([false, true])('reports app-server failure only when silent=%s is false', async (silent) => { + writeFakeCodexOptions(path.join(homeDir, '.codex'), { failMethod: 'hooks/list' }); + await pull({ force: true, silent }); + const warnings = vi.mocked(log.warn).mock.calls.map(([message]) => message).filter((message) => message.includes('Could not trust')); + expect(warnings).toHaveLength(silent ? 0 : 1); + if (!silent) expect(warnings[0]).toContain('hooks/list: fake failure'); + }); + + it('keeps an interactive pull quiet when Codex is absent', async () => { + const empty = path.join(tempDir, 'empty-bin'); + await fse.ensureDir(empty); + vi.stubEnv('PATH', empty); + await pull({ force: true }); + expect(vi.mocked(log.warn).mock.calls.map(([message]) => message).filter((message) => /trust.*Codex|Codex.*trust/i.test(message))).toEqual([]); + }); + }); diff --git a/src/codex-trust.ts b/src/codex-trust.ts index cd6306c1c..d7c217ac1 100644 --- a/src/codex-trust.ts +++ b/src/codex-trust.ts @@ -226,7 +226,16 @@ export async function trustCodexHooks(req: CodexHookTrustRequest): Promise => { const level = project ? await ensureProjectTrusted(server, project) : undefined; - const toTrust = (await listHooks(server, req.cwd)).filter((h) => + const listed = await listHooks(server, req.cwd); + const loaded = new Set(listed.map((h) => hookId(h.sourcePath, h.command))); + const missing = req.hooks.filter((h) => !loaded.has(hookId(h.file, h.command))); + if (missing.length > 0 && level !== 'untrusted') { + return { + kind: 'failed', + reason: `hooks/list: ${missing.map((h) => `${h.command} in ${h.file}`).join('; ')} not loaded for ${req.cwd}. Check that Codex loads these hook files, then run teamai pull again.`, + }; + } + const toTrust = listed.filter((h) => h.trustStatus !== 'trusted' && wanted.has(hookId(h.sourcePath, h.command))); if (toTrust.length > 0) { await batchWrite( diff --git a/src/doctor.ts b/src/doctor.ts index 1a7db76a2..d21209ac9 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -361,13 +361,15 @@ async function codexHookTrust(ctx: DoctorContext): Promise<{ checks: Check[]; no const reason = report.kind === 'failed' ? ` (could not ask Codex: ${report.reason})` : ''; return { checks: [], notes: [`${codexTrustReminder()}${reason}`] }; } + const notLoaded = report.notTrusted.some((h) => h.status === 'not loaded'); const notTrusted = report.notTrusted.map((h) => `${h.command} in ${h.file} (${h.status})`); return { checks: [{ name: 'Codex trusts the teamai hooks', source: 'local', check: async () => notTrusted.length === 0, - fix: `Codex will not run: ${notTrusted.join('; ')}. Run \`teamai pull\` to trust them, ` + fix: (notLoaded ? 'For hooks not loaded in a linked worktree, create `.codex/` or start Codex there, then open a new session. ' : '') + + `Codex will not run: ${notTrusted.join('; ')}. Run \`teamai pull\` to trust them, ` + 'or trust them in Codex /hooks. If `codexTrustEnabled: false` is set in config.yaml, ' + 'teamai leaves trusting them to you.', }], diff --git a/src/hooks.ts b/src/hooks.ts index 2cc09142c..ffc196115 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -1940,16 +1940,18 @@ interface CodexTrustTargets { mcpRecords: unknown[]; } -/** The teamai entries of one Codex hooks file: built-ins by marker, team hooks by manifest. */ +/** The teamai entries of one Codex hooks file: built-ins by exact rendered command, team hooks by manifest. */ async function teamaiCodexHooks(file: string, manifestPath: string): Promise> { const json = await readJson(file); if (!json?.hooks) return []; const teamCommands = new Set(((await readManifest(manifestPath))[CODEX_TOOL_ID] ?? []).map((r) => r.command)); + const builtinCommands = new Set(builtinHookDefs(CODEX_TOOL_ID).flatMap((def) => + toCodexEntry(def).hooks.map((hook) => hook.command))); return Object.values(json.hooks) .flatMap((groups) => (groups ?? []).flatMap((group) => group.hooks ?? [])) .map((hook) => hook.command) .filter((command) => typeof command === 'string' - && (TEAMAI_COMMAND_MARKERS.some((marker) => command.includes(marker)) || teamCommands.has(command))) + && (builtinCommands.has(command) || teamCommands.has(command))) .map((command) => ({ file, command })); } @@ -1984,7 +1986,7 @@ async function codexTrustTargets( sources.push({ file: mainFile, manifestPath: mainCheckout.manifestPath }); } else if (isSelfMode(localConfig) && path.join(anchor, paths.settings) !== primary) { // A linked worktree of a self repo: Codex reads the main checkout's file. - sources.push({ file: path.join(anchor, paths.settings), manifestPath }); + sources[0] = { file: path.join(anchor, paths.settings), manifestPath }; } } const hooks = (await Promise.all(sources.map((s) => teamaiCodexHooks(s.file, s.manifestPath)))).flat(); @@ -2000,7 +2002,7 @@ async function codexTrustTargets( const codexHome = resolveToolRootDir(CODEX_TOOL_ID, DEFAULT_CODEX_ROOT, localConfig.toolRoots); return { codexHome, - cwd: localConfig.projectRoot ?? baseDir, + cwd: anchor ?? baseDir, hooks, ...(anchor && projectLayer ? { project: anchor } : {}), inputs: [...sources.map((s) => s.file), path.join(codexHome, 'config.toml')], @@ -2076,7 +2078,7 @@ export async function readCodexHookTrustForScope( const targets = await codexTrustTargets(teamConfig, localConfig); if (!targets || targets.hooks.length === 0) return null; const { readCodexHookTrust } = await import('./codex-trust.js'); - return readCodexHookTrust(targets); + return readCodexHookTrust({ ...targets, cwd: localConfig.projectRoot ?? targets.cwd }); } /** diff --git a/src/pull.ts b/src/pull.ts index f098ed968..f7f47b8b2 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -2615,11 +2615,7 @@ async function reconcileHooksAllScopes( } } -/** - * Reconcile team MCP servers across all active scopes. MCP servers load at - * session start, so a change applied here takes effect in the user's next - * session — which is exactly when the SessionStart pull hook runs. - */ +/** Trust Codex hooks and projects after both hooks and MCP reconciliation. */ async function trustCodexAllScopes( userConfig: LocalConfig | null, projectConfig: LocalConfig | null, @@ -2641,6 +2637,11 @@ async function trustCodexAllScopes( } } +/** + * Reconcile team MCP servers across all active scopes. MCP servers load at + * session start, so a change applied here takes effect in the user's next + * session — which is exactly when the SessionStart pull hook runs. + */ async function reconcileMcpAllScopes( userConfig: LocalConfig | null, projectConfig: LocalConfig | null, From 2c71bd40538e764bc7e526c26bb1aa5d5d5ce197 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 01:41:02 +0200 Subject: [PATCH 03/13] fix(hooks): close Codex app-server after initialization failure --- src/__tests__/codex-trust.test.ts | 15 +++++++++++++++ src/__tests__/helpers/fake-codex.ts | 2 ++ src/codex-trust.ts | 13 +++++++++---- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/__tests__/codex-trust.test.ts b/src/__tests__/codex-trust.test.ts index ccb41bf81..2a2db9444 100644 --- a/src/__tests__/codex-trust.test.ts +++ b/src/__tests__/codex-trust.test.ts @@ -179,6 +179,21 @@ describe('Codex hook trust — user scope', () => { expect(reconciled.codexTrust).toEqual({ kind: 'failed', reason: 'hooks/list: fake failure' }); }); + it('closes the app-server when initialization fails', async () => { + writeFakeCodexOptions(codexHome(), { failMethod: 'initialize' }); + const result = await writeAndTrust(userConfig()); + expect(result.codexTrust).toEqual({ kind: 'failed', reason: 'initialize: fake failure' }); + const pid = readFakeCodexState(codexHome()).pid!; + const running = () => { + try { process.kill(pid, 0); return true; } catch { return false; } + }; + try { + await expect.poll(running, { timeout: 1000 }).toBe(false); + } finally { + if (running()) process.kill(pid); + } + }); + it('reports a failure when the app-server exits before answering', async () => { writeFakeCodexOptions(codexHome(), { exit: true }); diff --git a/src/__tests__/helpers/fake-codex.ts b/src/__tests__/helpers/fake-codex.ts index 98ee2e22e..6aca81e9b 100644 --- a/src/__tests__/helpers/fake-codex.ts +++ b/src/__tests__/helpers/fake-codex.ts @@ -63,6 +63,7 @@ process.stdin.on('data', (chunk) => { if (!line.trim()) continue; const m = JSON.parse(line); const s = state(); + s.pid = process.pid; s.calls.push({ method: m.method, params: m.params }); save(s); if (m.id === undefined) continue; @@ -84,6 +85,7 @@ process.stdin.on('data', (chunk) => { `; export interface FakeCodexState { + pid?: number; projects: Record; hooksState: Record; calls: Array<{ method: string; params: unknown }>; diff --git a/src/codex-trust.ts b/src/codex-trust.ts index d7c217ac1..9de451897 100644 --- a/src/codex-trust.ts +++ b/src/codex-trust.ts @@ -22,7 +22,7 @@ export type CodexTrust = | { kind: 'disabled' } /** No `codex` on PATH. */ | { kind: 'unavailable'; reason: string } - /** `codex app-server` failed, timed out or answered with an error. */ + /** The app-server failed, timed out, or did not load a requested hook. */ | { kind: 'failed'; reason: string }; export interface CodexHookTrustRequest { @@ -143,9 +143,14 @@ async function openAppServer(codexHome: string): Promise { child.kill(); }, }; - await server.request('initialize', { clientInfo: { name: 'teamai', version: getCurrentVersion() } }); - send({ method: 'initialized' }); - return server; + try { + await server.request('initialize', { clientInfo: { name: 'teamai', version: getCurrentVersion() } }); + send({ method: 'initialized' }); + return server; + } catch (e) { + server.close(); + throw e; + } } type AppServerFailure = { kind: 'unavailable'; reason: string } | { kind: 'failed'; reason: string }; From a18899fecd1b2ce062db7e7d6bd706c1d42004c2 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 01:44:02 +0200 Subject: [PATCH 04/13] test(hooks): mock Git worktree boundaries in unit fixtures --- src/__tests__/codex-trust.test.ts | 37 ++++++++++----------- src/__tests__/hooks-reconcile-scope.test.ts | 24 +++++++------ src/__tests__/pull-codex-trust.test.ts | 13 +++++--- 3 files changed, 40 insertions(+), 34 deletions(-) diff --git a/src/__tests__/codex-trust.test.ts b/src/__tests__/codex-trust.test.ts index 2a2db9444..666f01709 100644 --- a/src/__tests__/codex-trust.test.ts +++ b/src/__tests__/codex-trust.test.ts @@ -1,15 +1,21 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import path from 'node:path'; import os from 'node:os'; -import { spawnSync } from 'node:child_process'; import fse from 'fs-extra'; +vi.mock('../utils/git.js', async (importOriginal) => ({ + ...await importOriginal(), + resolveAnchors: vi.fn().mockResolvedValue(null), + listWorktrees: vi.fn().mockResolvedValue([]), +})); + vi.mock('../utils/logger.js', () => ({ log: { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), persist: vi.fn() }, })); import { readCodexHookTrustForScope, reconcileTeamHooksForConfig, reportCodexTrust, trustCodexForScope } from '../hooks.js'; import { trustCodexHooks, trustCodexProject } from '../codex-trust.js'; +import { resolveAnchors, listWorktrees } from '../utils/git.js'; import { log } from '../utils/logger.js'; import type { LocalConfig, TeamaiConfig } from '../types.js'; import { installFakeCodex, readFakeCodexState, writeFakeCodexOptions } from './helpers/fake-codex.js'; @@ -77,6 +83,8 @@ async function codexEntries(file: string): Promise { + vi.mocked(resolveAnchors).mockReset().mockResolvedValue(null); + vi.mocked(listWorktrees).mockReset().mockResolvedValue([]); home = await fse.realpath(await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-codex-trust-home-'))); repo = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-codex-trust-repo-')); fakeBin = installFakeCodex(); @@ -391,14 +399,10 @@ describe('Codex trust — project scopes', () => { }); it('a self worktree trusts the main checkout\'s hooks, which Codex reads there', async () => { - const git = (cwd: string, ...args: string[]) => { - const r = spawnSync('git', args, { cwd, encoding: 'utf8' }); - if (r.status !== 0) throw new Error(r.stderr); - }; - git(project, 'init', '-q'); - git(project, '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-q', '--allow-empty', '-m', 'init'); const worktree = `${project}-wt`; - git(project, 'worktree', 'add', '-q', '--detach', worktree); + await fse.ensureDir(worktree); + vi.mocked(resolveAnchors).mockImplementation(async (cwd) => ({ workspaceRoot: cwd ?? project, projectAnchor: project })); + vi.mocked(listWorktrees).mockResolvedValue([project, worktree]); try { for (const root of [project, worktree]) { await fse.ensureDir(path.join(root, '.codex')); @@ -412,29 +416,24 @@ describe('Codex trust — project scopes', () => { expect(trustedKeys()).toEqual(expect.arrayContaining(mainEntries.map((e) => e.key))); expect(readFakeCodexState(codexHome()).projects[project]).toEqual({ trust_level: 'trusted' }); } finally { - git(project, 'worktree', 'remove', '--force', worktree); + await fse.remove(worktree); } }); it('shares the trusted fingerprint when alternating project checkouts', async () => { - const git = (...args: string[]) => { - const result = spawnSync('git', args, { cwd: project, encoding: 'utf8' }); - if (result.status !== 0) throw new Error(result.stderr); - }; - git('init', '-q'); - git('-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-q', '--allow-empty', '-m', 'init'); const worktree = `${project}-wt`; - git('worktree', 'add', '-q', '--detach', worktree); + await fse.ensureDir(worktree); + vi.mocked(resolveAnchors).mockImplementation(async (cwd) => ({ workspaceRoot: cwd ?? project, projectAnchor: project })); + vi.mocked(listWorktrees).mockResolvedValue([project, worktree]); try { await writeYaml(LINT_HOOK); - const { resolveProjectDataHome } = await import('../config.js'); - const dataHome = await resolveProjectDataHome(project); + const dataHome = path.join(home, '.teamai', 'shared-project'); await writeAndTrust(projectConfig({ projectRoot: worktree, dataHome })); await writeAndTrust(projectConfig({ dataHome })); await writeAndTrust(projectConfig({ projectRoot: worktree, dataHome })); expect(calls('initialize')).toBe(1); } finally { - git('worktree', 'remove', '--force', worktree); + await fse.remove(worktree); } }); diff --git a/src/__tests__/hooks-reconcile-scope.test.ts b/src/__tests__/hooks-reconcile-scope.test.ts index f0a9458b6..57f1c8173 100644 --- a/src/__tests__/hooks-reconcile-scope.test.ts +++ b/src/__tests__/hooks-reconcile-scope.test.ts @@ -4,10 +4,17 @@ import os from 'node:os'; import { spawnSync } from 'node:child_process'; import fse from 'fs-extra'; +vi.mock('../utils/git.js', async (importOriginal) => ({ + ...await importOriginal(), + resolveAnchors: vi.fn().mockResolvedValue(null), + listWorktrees: vi.fn().mockResolvedValue([]), +})); + vi.mock('../utils/logger.js', () => ({ log: { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), persist: vi.fn() }, })); +import { resolveAnchors, listWorktrees } from '../utils/git.js'; import { reconcileTeamHooksForConfig } from '../hooks.js'; import type { LocalConfig, TeamaiConfig } from '../types.js'; @@ -67,6 +74,8 @@ function mainManifest(): Promise>> { } beforeEach(async () => { + vi.mocked(resolveAnchors).mockReset().mockResolvedValue(null); + vi.mocked(listWorktrees).mockReset().mockResolvedValue([]); project = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-recon-proj-')); repo = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-recon-repo-')); home = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-recon-home-')); @@ -473,17 +482,12 @@ builtin: describe('reconcileTeamHooksForConfig — team hooks in the main checkout', () => { const STOP_LINT = 'hooks:\n - id: lint\n description: lint\n event: Stop\n command: npm run lint\n'; - function git(cwd: string, ...args: string[]): void { - const result = spawnSync('git', args, { cwd, encoding: 'utf8' }); - if (result.status !== 0) throw new Error(`git ${args.join(' ')}: ${result.stderr}`); - } - async function mainWithWorktree(): Promise<{ main: string; worktree: string }> { const main = await fse.realpath(project); - git(main, 'init', '-q'); - git(main, '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-q', '--allow-empty', '-m', 'init'); const worktree = path.join(await fse.realpath(os.tmpdir()), `teamai-recon-wt-${path.basename(main)}`); - git(main, 'worktree', 'add', '-q', '--detach', worktree); + await fse.ensureDir(worktree); + vi.mocked(resolveAnchors).mockImplementation(async (cwd) => ({ workspaceRoot: cwd ?? main, projectAnchor: main })); + vi.mocked(listWorktrees).mockResolvedValue([main, worktree]); return { main, worktree }; } @@ -503,7 +507,7 @@ describe('reconcileTeamHooksForConfig — team hooks in the main checkout', () = // The SessionStart a new Codex worktree runs before it has a `.codex/`. expect((await codexSettings()).hooks.SessionStart).toHaveLength(1); } finally { - git(main, 'worktree', 'remove', '--force', worktree); + await fse.remove(worktree); } }); @@ -527,7 +531,7 @@ describe('reconcileTeamHooksForConfig — team hooks in the main checkout', () = expect(stop.filter((c) => c.startsWith('if [ "$PWD"'))).toEqual([gated(other, 'npm run lint')]); expect(((await manifest()).codex as unknown as Array<{ command: string }>).map((r) => r.command)).toEqual([gated(other, 'npm run lint')]); } finally { - git(main, 'worktree', 'remove', '--force', worktree); + await fse.remove(worktree); await fse.remove(other); } }); diff --git a/src/__tests__/pull-codex-trust.test.ts b/src/__tests__/pull-codex-trust.test.ts index fe37f67df..ce0b3ce2e 100644 --- a/src/__tests__/pull-codex-trust.test.ts +++ b/src/__tests__/pull-codex-trust.test.ts @@ -11,7 +11,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import fse from 'fs-extra'; import os from 'node:os'; import path from 'node:path'; -import { execFileSync } from 'node:child_process'; vi.mock('../config.js', async (importOriginal) => ({ ...(await importOriginal()), @@ -25,6 +24,8 @@ vi.mock('../config.js', async (importOriginal) => ({ vi.mock('../utils/git.js', async (importOriginal) => ({ ...await importOriginal(), + resolveAnchors: vi.fn().mockResolvedValue(null), + listWorktrees: vi.fn().mockResolvedValue([]), getHeadRev: vi.fn().mockResolvedValue('abc1234'), pullRepo: vi.fn().mockResolvedValue('already up to date'), })); @@ -85,6 +86,7 @@ vi.mock('../mcp-reconcile.js', async (importOriginal) => ({ })); import { detectProjectConfig, loadLocalConfigForScope, loadTeamConfig } from '../config.js'; +import { resolveAnchors, listWorktrees } from '../utils/git.js'; import { pull } from '../pull.js'; import { reconcileMcpForConfig } from '../mcp-reconcile.js'; import { getDataHome, managedMcpManifestKey, managedMcpManifestPath } from '../types.js'; @@ -102,6 +104,8 @@ describe('pull trusts the Codex project after writing its MCP servers', () => { let teamConfig: TeamaiConfig; beforeEach(async () => { + vi.mocked(resolveAnchors).mockReset().mockResolvedValue(null); + vi.mocked(listWorktrees).mockReset().mockResolvedValue([]); tempDir = await fse.realpath(await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-pull-codex-trust-'))); homeDir = path.join(tempDir, 'home'); project = path.join(tempDir, 'project'); @@ -157,11 +161,10 @@ describe('pull trusts the Codex project after writing its MCP servers', () => { expect(readFakeCodexState(path.join(homeDir, '.codex')).projects[project]).toEqual({ trust_level: 'trusted' }); }); it('pull from a worktree without .codex trusts the main keys before SessionStart', async () => { - const git = (...args: string[]) => execFileSync('git', args, { cwd: project }); - git('init', '-q'); - git('-c', 'user.name=test', '-c', 'user.email=test@test', 'commit', '-q', '--allow-empty', '-m', 'init'); const worktree = path.join(tempDir, 'worktree'); - git('worktree', 'add', '-q', '--detach', worktree); + await fse.ensureDir(worktree); + vi.mocked(resolveAnchors).mockImplementation(async (cwd) => ({ workspaceRoot: cwd ?? project, projectAnchor: project })); + vi.mocked(listWorktrees).mockResolvedValue([project, worktree]); localConfig.projectRoot = worktree; writeFakeCodexOptions(path.join(homeDir, '.codex'), { projectLayers: { [worktree]: project } }); vi.mocked(reconcileMcpForConfig).mockResolvedValue({ changes: [], wrote: false } as never); From fd877815cb4d163f8e5ffa962f85309c09c962ea Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 01:47:28 +0200 Subject: [PATCH 05/13] fix(hooks): trust Codex built-ins after unresolved team-hook fallback --- src/__tests__/hooks-cmd.test.ts | 17 ++++++++++++++++- src/hooks-cmd.ts | 3 +-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/__tests__/hooks-cmd.test.ts b/src/__tests__/hooks-cmd.test.ts index 646c31417..9d8bb0d48 100644 --- a/src/__tests__/hooks-cmd.test.ts +++ b/src/__tests__/hooks-cmd.test.ts @@ -194,9 +194,24 @@ describe('hooksInject', () => { expect(mockedLog.warn).not.toHaveBeenCalled(); }); + it.each([false, true])('trusts fallback built-ins while preserving inject failure, silent=%s', async (silent) => { + mockedReconcileForConfig.mockResolvedValue({ ok: false, builtins: 'defaults-where-none' }); + mockedTrustCodex.mockResolvedValue({ kind: 'trusted', hooks: 8 }); + try { + await hooksInject({ silent }); + expect(mockedTrustCodex).toHaveBeenCalledWith(mockTeamConfig, mockLocalConfig, { force: true }); + expect(process.exitCode).toBe(1); + expect(mockedLog.success).not.toHaveBeenCalledWith(expect.stringContaining('Hooks injected')); + if (silent) expect(mockedLog.success).not.toHaveBeenCalled(); + else expect(mockedLog.success).toHaveBeenCalledWith('Trusted 8 teamai hook(s) in Codex'); + } finally { + process.exitCode = undefined; + } + }); + it('fails, without the success line, when the team hooks cannot be resolved', async () => { // The reconcile reported why (a broken hooks file, a hook id twice) and - // left every installed hook as it was. + // preserved the team hooks and reconciled the built-ins. mockedReconcileForConfig.mockResolvedValue({ ok: false }); try { await hooksInject({}); diff --git a/src/hooks-cmd.ts b/src/hooks-cmd.ts index 9fa44502f..b7fcfee23 100644 --- a/src/hooks-cmd.ts +++ b/src/hooks-cmd.ts @@ -105,7 +105,6 @@ export async function hooksInject(options: GlobalOptions): Promise { // The reason is already reported; the installed team hooks were left as they were. if (!reconciled.ok) { process.exitCode = 1; - return; } // The public Codex skips a hook it does not trust, so trust what was just // written (#955). An explicit inject always asks Codex, whatever the last @@ -113,7 +112,7 @@ export async function hooksInject(options: GlobalOptions): Promise { const codexTrust = await trustCodexForScope(teamConfig, localConfig, { force: true }); if (!options.silent) { - log.success('Hooks injected into all AI tool settings'); + if (reconciled.ok) log.success('Hooks injected into all AI tool settings'); reportCodexTrust(codexTrust, 'all'); } } From a77c525cef2dbce51caa7754b83cef530ea25af9 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 04:44:07 +0200 Subject: [PATCH 06/13] fix(hooks): preserve project isolation in removal and e2e (#955) --- docs/usage-guide.md | 2 + docs/usage-guide.zh-CN.md | 2 + skill-data/core/references/troubleshooting.md | 2 + .../hooks-project-isolation-issue373.test.ts | 91 +++++++++++++++++-- src/__tests__/e2e/namespaced-entries.test.ts | 20 +++- .../e2e/project-scoped-delivery.test.ts | 19 +++- .../e2e/scope-isolation-issue85.test.ts | 83 +++++++++++------ src/__tests__/hooks-cmd.test.ts | 6 +- src/hooks-cmd.ts | 4 + 9 files changed, 187 insertions(+), 42 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 72f34484f..95a0cb60a 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1920,6 +1920,8 @@ teamai hooks remove # Remove The inject and remove commands only touch tools you actually have installed (i.e. whose `~/./` root directory already exists). They never create root directories for tools listed in `toolPaths` but not installed. +In non-self project scope, `hooks remove` removes this checkout's gated team hooks from HOME and Claude/Codex team hooks from the main checkout. Other projects' gated team hooks stay in HOME; shared built-in hooks are removed. + On Windows, the built-in hook dispatch commands that shell out through bash (e.g. Claude, Codex, Cursor, Copilot CLI) reference Git Bash by absolute path — standard install locations first, then the `HKLM\SOFTWARE\GitForWindows` registry as fallback — so they never resolve to the WSL `bash.exe` launcher; if Git Bash cannot be found they degrade to bare `bash`. Cursor also loads `~/.claude/settings.json`, and Copilot CLI loads a trusted project's `.claude/settings.json` (self mode writes hooks there; Copilot does not load `~/.claude/settings.json`). `hook-dispatch --tool claude` exits only when that other host's own teamai hooks are on disk: `~/.cursor/hooks.json` or `$CURSOR_PROJECT_DIR/.cursor/hooks.json` contains `--tool cursor`, or `$COPILOT_PROJECT_DIR/.github/hooks/teamai.json` contains `--tool copilot`. Team hook commands written for `claude` use the same check. A setup with only Claude keeps running inside Cursor, because there is no second copy. `COPILOT_CLI` is not a signal: Copilot sets it on every subprocess, including a Claude session started from its shell. Claude Code sets neither `CURSOR_VERSION` nor `COPILOT_PROJECT_DIR`. Run `teamai pull` or `teamai hooks inject` again so an already installed team hook picks up the guard. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 9cf570c2e..1a42c29c0 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1785,6 +1785,8 @@ teamai hooks remove # 移除 inject 和 remove 只会操作你实际已安装的工具(即 `~/./` 根目录已存在的工具)。对于 `toolPaths` 中已配置但未安装的工具,命令不会为其凭空创建根目录。 +非-self 的 project scope 中,`hooks remove` 会移除 HOME 中当前 checkout 的门控团队 hooks,以及主 checkout 中 Claude/Codex 的团队 hooks。其他项目的门控团队 hooks 保留在 HOME;共享的内置 hooks 会被移除。 + 在 Windows 上,经由 bash 执行的内置 hook 派发命令(如 Claude、Codex、Cursor、Copilot CLI)会以绝对路径引用 Git Bash——先查标准安装位置,再回退到 `HKLM\SOFTWARE\GitForWindows` 注册表——从而避免解析到 WSL 的 `bash.exe`;若找不到 Git Bash,则退回裸 `bash`。 Cursor 也会加载 `~/.claude/settings.json`。Copilot CLI 会加载受信任项目里的 `.claude/settings.json`(self mode 把 hook 写在项目里;Copilot 不加载 `~/.claude/settings.json`)。只有另一边的 teamai hook 已经在磁盘上时,`hook-dispatch --tool claude` 才会退出:`~/.cursor/hooks.json` 或 `$CURSOR_PROJECT_DIR/.cursor/hooks.json` 含有 `--tool cursor`,或 `$COPILOT_PROJECT_DIR/.github/hooks/teamai.json` 含有 `--tool copilot`。写给 `claude` 的团队 hook 命令用同一判断。只启用了 Claude 时,Cursor 里这份 hook 照常运行,因为没有第二份可以接替。`COPILOT_CLI` 不能当信号:Copilot 会给每个子进程设置它,包括从它的 shell 里启动的 Claude。Claude Code 不会设置 `CURSOR_VERSION` 或 `COPILOT_PROJECT_DIR`。已经装好的团队 hook 需要再跑一次 `teamai pull` 或 `teamai hooks inject`,才会带上这个判断。 diff --git a/skill-data/core/references/troubleshooting.md b/skill-data/core/references/troubleshooting.md index 91d49577e..ed8291fec 100644 --- a/skill-data/core/references/troubleshooting.md +++ b/skill-data/core/references/troubleshooting.md @@ -32,6 +32,8 @@ This is the #1 onboarding issue. In order: settings (e.g. `~/.claude/settings.json`), not the project folder; the team's own hooks for Claude Code and Codex go to the main checkout (`.claude/settings.local.json`, `.codex/hooks.json`). That is intentional. + In project scope, `teamai hooks remove` preserves other projects' gated team + hooks in HOME, while removing the shared built-in hooks. If you initialized project scope but expected machine-wide resources, re-run with `--scope user`. 5. **Tool has no hook surface** (e.g. Gemini CLI, JoyCode): there is no auto-sync; diff --git a/src/__tests__/e2e/hooks-project-isolation-issue373.test.ts b/src/__tests__/e2e/hooks-project-isolation-issue373.test.ts index 480f6db3e..f441780dd 100644 --- a/src/__tests__/e2e/hooks-project-isolation-issue373.test.ts +++ b/src/__tests__/e2e/hooks-project-isolation-issue373.test.ts @@ -8,9 +8,9 @@ import { fileURLToPath } from 'node:url'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); const CLI = path.join(ROOT, 'dist', 'index.js'); -function runCLI(cwd: string, home: string): Promise<{ code: number | null; output: string }> { +function runCLI(cwd: string, home: string, action = 'inject'): Promise<{ code: number | null; output: string }> { return new Promise((resolve) => { - const child = spawn('node', [CLI, 'hooks', 'inject'], { + const child = spawn('node', [CLI, 'hooks', action], { cwd, env: { ...process.env, HOME: home, FORCE_COLOR: '0' }, stdio: ['pipe', 'pipe', 'pipe'], @@ -28,6 +28,14 @@ describe('issue #373 project hook isolation (real CLI)', () => { let home: string; let projectA: string; let projectB: string; + let worktreeA: string; + + type Settings = { hooks: { SessionStart?: Array; Stop: Array<{ description?: string; hooks: Array<{ command: string }> }> } }; + const readSettings = (file: string): Settings => JSON.parse(fs.readFileSync(file, 'utf8')) as Settings; + const mainFiles = (project: string): string[] => [ + path.join(project, '.claude', 'settings.local.json'), + path.join(project, '.codex', 'hooks.json'), + ]; beforeAll(() => { if (!fs.existsSync(CLI)) throw new Error('Run npm run build before e2e tests'); @@ -35,6 +43,10 @@ describe('issue #373 project hook isolation (real CLI)', () => { home = path.join(sandbox, 'home'); projectA = path.join(sandbox, 'project-a'); projectB = path.join(sandbox, 'project-b'); + worktreeA = path.join(sandbox, 'worktree-a'); + for (const tool of ['.claude', '.codex', '.codebuddy']) { + fs.mkdirSync(path.join(home, tool), { recursive: true }); + } for (const project of [projectA, projectB]) { fs.mkdirSync(path.join(project, '.claude'), { recursive: true }); fs.mkdirSync(path.join(project, '.teamai', 'team-repo', 'hooks'), { recursive: true }); @@ -44,16 +56,21 @@ describe('issue #373 project hook isolation (real CLI)', () => { ' remote: https://example.test/team.git', 'username: e2e', 'scope: project', + 'codexTrustEnabled: false', `projectRoot: ${project}`, ].join('\n') + '\n'); } fs.writeFileSync(path.join(projectA, '.teamai', 'team-repo', 'teamai.yaml'), [ 'team: e2e-team', 'repo: https://example.test/team.git', 'toolPaths:', ' claude:', ' settings: .claude/settings.json', + ' codex:', ' settings: .codex/hooks.json', + ' codebuddy:', ' settings: .codebuddy/settings.json', ].join('\n') + '\n'); fs.writeFileSync(path.join(projectB, '.teamai', 'team-repo', 'teamai.yaml'), [ 'team: e2e-team', 'repo: https://example.test/team.git', 'toolPaths:', ' claude:', ' settings: .claude/settings.json', + ' codex:', ' settings: .codex/hooks.json', + ' codebuddy:', ' settings: .codebuddy/settings.json', ].join('\n') + '\n'); fs.writeFileSync(path.join(projectA, '.teamai', 'team-repo', 'hooks', 'hooks.yaml'), [ 'hooks:', ' - id: a', ' description: project a', ' event: Stop', ' command: echo A', @@ -61,6 +78,19 @@ describe('issue #373 project hook isolation (real CLI)', () => { fs.writeFileSync(path.join(projectB, '.teamai', 'team-repo', 'hooks', 'hooks.yaml'), [ 'hooks:', ' - id: b', ' description: project b', ' event: Stop', ' command: echo B', ].join('\n') + '\n'); + const gitEnv = { + ...process.env, + GIT_AUTHOR_NAME: 'TeamAI CI', GIT_AUTHOR_EMAIL: 'ci@teamai.test', + GIT_COMMITTER_NAME: 'TeamAI CI', GIT_COMMITTER_EMAIL: 'ci@teamai.test', + }; + execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: projectA, env: gitEnv }); + execFileSync('git', ['commit', '-q', '--allow-empty', '-m', 'fixture'], { cwd: projectA, env: gitEnv }); + execFileSync('git', ['worktree', 'add', '-q', '-b', 'worktree-a', worktreeA], { cwd: projectA, env: gitEnv }); + // Both legacy configs point at the same team repo; git resolves their main checkout. + fs.mkdirSync(path.join(worktreeA, '.teamai'), { recursive: true }); + fs.writeFileSync(path.join(worktreeA, '.teamai', 'config.yaml'), + fs.readFileSync(path.join(projectA, '.teamai', 'config.yaml'), 'utf8') + .replace(`projectRoot: ${projectA}`, `projectRoot: ${worktreeA}`)); }); afterAll(() => { if (sandbox) fs.rmSync(sandbox, { recursive: true, force: true }); }); @@ -71,17 +101,60 @@ describe('issue #373 project hook isolation (real CLI)', () => { expect(a.code, a.output).toBe(0); expect(b.code, b.output).toBe(0); - const settingsPath = path.join(home, '.claude', 'settings.json'); - const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as { hooks: { Stop: Array<{ description?: string; hooks: Array<{ command: string }> }> } }; - const team = settings.hooks.Stop.filter((entry) => entry.description?.startsWith('[teamai:hook:')); + for (const [project, command] of [[projectA, 'echo A'], [projectB, 'echo B']]) { + for (const file of mainFiles(project)) { + const settings = readSettings(file); + expect(settings.hooks.Stop.map((entry) => entry.hooks[0].command)).toEqual([command]); + expect(settings.hooks.SessionStart).toBeUndefined(); + } + } + for (const file of ['.claude/settings.json', '.codex/hooks.json']) { + const settings = readSettings(path.join(home, file)); + expect(settings.hooks.SessionStart).toHaveLength(1); + expect(settings.hooks.Stop.map((entry) => entry.hooks[0].command).join('\n')).not.toMatch(/echo [AB]/); + } + + // CodeBuddy still shares HOME and gates each project's team hooks by cwd. + const settingsPath = path.join(home, '.codebuddy', 'settings.json'); + const team = readSettings(settingsPath).hooks.Stop.filter((entry) => entry.description?.startsWith('[teamai:hook:')); expect(team).toHaveLength(2); const commandA = team.find((entry) => entry.hooks[0].command.includes('echo A'))!.hooks[0].command; const commandB = team.find((entry) => entry.hooks[0].command.includes('echo B'))!.hooks[0].command; - expect(execFileSync('sh', ['-c', commandA], { cwd: projectA, encoding: 'utf8' })).toContain('A'); + expect(commandA).toContain('$PWD'); + expect(commandB).toContain('$PWD'); + expect(execFileSync('sh', ['-c', commandA], { cwd: projectA, encoding: 'utf8' })).toBe('A\n'); expect(execFileSync('sh', ['-c', commandA], { cwd: projectB, encoding: 'utf8' })).toBe(''); - expect(execFileSync('sh', ['-c', commandB], { cwd: projectB, encoding: 'utf8' })).toContain('B'); + expect(execFileSync('sh', ['-c', commandB], { cwd: projectB, encoding: 'utf8' })).toBe('B\n'); + expect(execFileSync('sh', ['-c', commandB], { cwd: projectA, encoding: 'utf8' })).toBe(''); + expect(JSON.parse(fs.readFileSync(path.join(home, '.teamai', 'managed-hooks.json'), 'utf8')).codebuddy).toHaveLength(2); + + const removed = await runCLI(projectA, home, 'remove'); + expect(removed.code, removed.output).toBe(0); + for (const file of mainFiles(projectA)) expect(readSettings(file).hooks.Stop ?? []).toEqual([]); + for (const file of mainFiles(projectB)) { + expect(readSettings(file).hooks.Stop.map((entry) => entry.hooks[0].command)).toEqual(['echo B']); + } + const remaining = readSettings(settingsPath).hooks.Stop.filter((entry) => entry.description?.startsWith('[teamai:hook:')); + expect(remaining.map((entry) => entry.hooks[0].command)).toEqual([commandB]); + expect(JSON.parse(fs.readFileSync(path.join(home, '.teamai', 'managed-hooks.json'), 'utf8')).codebuddy).toHaveLength(1); + for (const project of [projectA, projectB]) { + expect(fs.existsSync(path.join(project, '.codebuddy', 'settings.json'))).toBe(false); + } + }); + + it('shares one ungated Claude/Codex team-hook file in the main checkout with a linked worktree', async () => { + const main = await runCLI(projectA, home); + expect(main.code, main.output).toBe(0); + const before = mainFiles(projectA).map((file) => fs.readFileSync(file, 'utf8')); - // A later project reconcile must not remove A's hook from shared HOME. - expect(JSON.parse(fs.readFileSync(path.join(home, '.teamai', 'managed-hooks.json'), 'utf8')).claude).toHaveLength(2); + const linked = await runCLI(worktreeA, home); + expect(linked.code, linked.output).toBe(0); + expect(mainFiles(projectA).map((file) => fs.readFileSync(file, 'utf8'))).toEqual(before); + for (const file of mainFiles(worktreeA)) expect(fs.existsSync(file)).toBe(false); + for (const file of mainFiles(projectA)) { + const [command] = readSettings(file).hooks.Stop.map((entry) => entry.hooks[0].command); + expect(command).toBe('echo A'); + expect(execFileSync('sh', ['-c', command], { cwd: worktreeA, encoding: 'utf8' })).toBe('A\n'); + } }); }); diff --git a/src/__tests__/e2e/namespaced-entries.test.ts b/src/__tests__/e2e/namespaced-entries.test.ts index f1be1b2da..ff20060e0 100644 --- a/src/__tests__/e2e/namespaced-entries.test.ts +++ b/src/__tests__/e2e/namespaced-entries.test.ts @@ -66,7 +66,17 @@ describe('env, hooks and MCP by namespace via the real CLI (#707)', () => { const mcpServers = (): Record => ( JSON.parse(fs.readFileSync(path.join(projectRoot, '.mcp.json'), 'utf8')).mcpServers ?? {} ); - const hookCommands = (): string => fs.readFileSync(path.join(home, '.claude', 'settings.json'), 'utf8'); + const hookCommands = (): string => { + const settings = fs.readFileSync(path.join(projectRoot, '.claude', 'settings.local.json'), 'utf8'); + expect(settings).not.toContain('$PWD'); + return settings; + }; + const codebuddyCommands = (): string => { + const settings = fs.readFileSync(path.join(home, '.codebuddy', 'settings.json'), 'utf8'); + expect(settings).toContain('$PWD'); + expect(fs.existsSync(path.join(projectRoot, '.codebuddy', 'settings.json'))).toBe(false); + return settings; + }; /** Commit a change in the seed checkout and publish it to the team remote. */ function publish(message: string, change: () => void): void { @@ -89,6 +99,7 @@ describe('env, hooks and MCP by namespace via the real CLI (#707)', () => { const teamRepo = path.join(projectRoot, '.teamai', 'team-repo'); fs.mkdirSync(path.join(home, '.claude'), { recursive: true }); + fs.mkdirSync(path.join(home, '.codebuddy'), { recursive: true }); fs.mkdirSync(path.join(projectRoot, '.claude', 'skills'), { recursive: true }); write(seed, 'teamai.yaml', [ @@ -102,6 +113,8 @@ describe('env, hooks and MCP by namespace via the real CLI (#707)', () => { ' settings: .claude/settings.json', ' mcp: .claude.json', ' mcpProject: .mcp.json', + ' codebuddy:', + ' settings: .codebuddy/settings.json', '', ].join('\n')); write(seed, 'manifest/projects.yaml', [ @@ -206,6 +219,8 @@ describe('env, hooks and MCP by namespace via the real CLI (#707)', () => { expect(hookCommands()).toContain('echo checkout-lint'); expect(hookCommands()).not.toContain('echo root-lint'); + expect(codebuddyCommands()).toContain('echo checkout-lint'); + expect(codebuddyCommands()).not.toContain('echo root-lint'); const envList = await runCLI(['env', 'list'], projectRoot, home); expect(envList.output).toContain('API_BASE=ht**** env.yaml (checkout, overrides root)'); @@ -243,6 +258,8 @@ describe('env, hooks and MCP by namespace via the real CLI (#707)', () => { expect(hookCommands()).toContain('echo root-lint'); expect(hookCommands()).not.toContain('echo checkout-lint'); + expect(codebuddyCommands()).toContain('echo root-lint'); + expect(codebuddyCommands()).not.toContain('echo checkout-lint'); }, 120_000); it('keeps what is installed when two active namespaces define one server, naming both files', async () => { @@ -287,5 +304,6 @@ describe('env, hooks and MCP by namespace via the real CLI (#707)', () => { // Only env stopped: the hooks reconcile, which runs after it, still applied // the change from the same commit. expect(hookCommands()).toContain('echo checkout-lint-v2'); + expect(codebuddyCommands()).toContain('echo checkout-lint-v2'); }, 120_000); }); diff --git a/src/__tests__/e2e/project-scoped-delivery.test.ts b/src/__tests__/e2e/project-scoped-delivery.test.ts index 8aeca8777..ad70e254c 100644 --- a/src/__tests__/e2e/project-scoped-delivery.test.ts +++ b/src/__tests__/e2e/project-scoped-delivery.test.ts @@ -72,7 +72,13 @@ describe('project-scoped hooks, MCP servers and env variables via the real CLI ( // In project scope Claude's MCP lands in /.mcp.json (toolPaths // `mcpProject`), not the user-scope ~/.claude.json. const readClaudeMcp = (): string => fs.readFileSync(path.join(projectRoot, '.mcp.json'), 'utf8'); - const claudeSettingsPath = (): string => path.join(home, '.claude', 'settings.json'); + const claudeSettingsPath = (): string => path.join(projectRoot, '.claude', 'settings.local.json'); + const codebuddySettings = (): string => { + const settings = fs.readFileSync(path.join(home, '.codebuddy', 'settings.json'), 'utf8'); + expect(settings).toContain('$PWD'); + expect(fs.existsSync(path.join(projectRoot, '.codebuddy', 'settings.json'))).toBe(false); + return settings; + }; beforeAll(() => { if (!fs.existsSync(CLI)) { @@ -87,6 +93,7 @@ describe('project-scoped hooks, MCP servers and env variables via the real CLI ( const teamRepo = path.join(projectRoot, '.teamai', 'team-repo'); fs.mkdirSync(home, { recursive: true }); + fs.mkdirSync(path.join(home, '.codebuddy'), { recursive: true }); // The MCP reconcile only targets a tool it considers installed, probed via // its skills dir — so the sandbox has to look like a Claude checkout. fs.mkdirSync(path.join(projectRoot, '.claude', 'skills'), { recursive: true }); @@ -242,7 +249,7 @@ describe('project-scoped hooks, MCP servers and env variables via the real CLI ( `projectRoot: ${projectRoot}`, 'primaryRole: frontend', 'additionalRoles: []', - 'enabledAgents: [claude, codex]', + 'enabledAgents: [claude, codex, codebuddy]', '', ].join('\n')); }); @@ -281,6 +288,10 @@ describe('project-scoped hooks, MCP servers and env variables via the real CLI ( expect(claudeSettings).toContain('echo checkout'); expect(claudeSettings).toContain('echo shared'); expect(claudeSettings).not.toContain('echo billing'); + expect(claudeSettings).not.toContain('$PWD'); + expect(codebuddySettings()).toContain('echo checkout'); + expect(codebuddySettings()).toContain('echo shared'); + expect(codebuddySettings()).not.toContain('echo billing'); // ── Upgrade path: repo unchanged, CLI newer ──────────────────────────── // A CLI that honoured `roles:` on env left DEVOPS_ONLY in env.sh, and the @@ -326,6 +337,10 @@ describe('project-scoped hooks, MCP servers and env variables via the real CLI ( const settingsBilling = fs.readFileSync(claudeSettingsPath(), 'utf8'); expect(settingsBilling).toContain('echo billing'); expect(settingsBilling).not.toContain('echo checkout'); + expect(settingsBilling).not.toContain('$PWD'); + expect(codebuddySettings()).toContain('echo billing'); + expect(codebuddySettings()).toContain('echo shared'); + expect(codebuddySettings()).not.toContain('echo checkout'); }, 120_000); it('reports where each entry comes from in mcp list, hooks list and env list', async () => { diff --git a/src/__tests__/e2e/scope-isolation-issue85.test.ts b/src/__tests__/e2e/scope-isolation-issue85.test.ts index bc21f7506..7e1d1e02c 100644 --- a/src/__tests__/e2e/scope-isolation-issue85.test.ts +++ b/src/__tests__/e2e/scope-isolation-issue85.test.ts @@ -11,9 +11,9 @@ import { fileURLToPath } from 'node:url'; // upvote scope (see scope-isolation-e2e.test.ts). This file drives the real // CLI binary against offline git fixtures to cover the four gaps that were // still open after those landed: -// 1. `hooks inject`/`hooks remove` must write only to the user's HOME -// directory (#264 simplified this: project scope no longer writes a -// redundant copy into projectRoot). +// 1. Claude/Codex team hooks live in the main checkout, ungated (#955); +// built-ins and other tools stay in HOME (#264), with team hooks gated +// by project cwd. Inject/remove must respect both ownership boundaries. // 2. `tags subscribe`/`unsubscribe` must write to the active scope's // config.yaml, not always ~/.teamai/config.yaml. // 3. `contribute` must make a new learning immediately recallable, without @@ -66,6 +66,10 @@ const TEAM_YAML = [ ' skills: .claude/skills', ' rules: .claude/rules', ' settings: .claude/settings.json', + ' codex:', + ' settings: .codex/hooks.json', + ' codebuddy:', + ' settings: .codebuddy/settings.json', ].join('\n'); const HOOKS_YAML = [ @@ -108,6 +112,8 @@ describe('issue #85 remaining scope-isolation gaps (e2e)', () => { fs.mkdirSync(homeDir, { recursive: true }); fs.mkdirSync(projectRoot, { recursive: true }); fs.mkdirSync(path.join(homeDir, '.claude', 'skills'), { recursive: true }); + fs.mkdirSync(path.join(homeDir, '.codex'), { recursive: true }); + fs.mkdirSync(path.join(homeDir, '.codebuddy'), { recursive: true }); fs.mkdirSync(path.join(projectRoot, '.claude', 'skills'), { recursive: true }); // ── User-scope fixture (present only so we can prove project-scope @@ -147,6 +153,7 @@ describe('issue #85 remaining scope-isolation gaps (e2e)', () => { 'username: ci-proj', 'updatePolicy: auto', 'scope: project', + 'codexTrustEnabled: false', ].join('\n'), ); }, 60_000); @@ -163,44 +170,66 @@ describe('issue #85 remaining scope-isolation gaps (e2e)', () => { }); }); - describe('hooks inject/remove manifest scoping (item 1, updated by #264)', () => { - it('inject writes only to the user HOME, not to projectRoot (#264)', async () => { + describe('hooks inject/remove manifest scoping (#264, #955)', () => { + const mainFiles = (): string[] => [ + path.join(projectRoot, '.claude', 'settings.local.json'), + path.join(projectRoot, '.codex', 'hooks.json'), + ]; + const homeFiles = (): string[] => [ + path.join(homeDir, '.claude', 'settings.json'), + path.join(homeDir, '.codex', 'hooks.json'), + path.join(homeDir, '.codebuddy', 'settings.json'), + ]; + + it('keeps Claude/Codex team hooks in the main checkout and gated CodeBuddy hooks only in HOME', async () => { const res = await runCLI(['hooks', 'inject'], { HOME: homeDir }, projectRoot); expect(res.code, res.output).toBe(0); - // #264: project scope no longer writes a redundant copy into projectRoot. - const projectManifestPath = path.join(projectRoot, '.teamai', 'managed-hooks.json'); - const userManifestPath = path.join(homeDir, '.teamai', 'managed-hooks.json'); - expect(fs.existsSync(projectManifestPath)).toBe(false); - expect(fs.existsSync(userManifestPath)).toBe(true); - - const userManifest = JSON.parse(fs.readFileSync(userManifestPath, 'utf-8')); - expect(userManifest.claude?.[0]?.command).toContain('teamai-e2e-hook-marker'); - - // Only HOME settings receives the hook; projectRoot is untouched. + expect(fs.existsSync(path.join(projectRoot, '.teamai', 'managed-hooks.json'))).toBe(false); + const userManifest = JSON.parse(fs.readFileSync(path.join(homeDir, '.teamai', 'managed-hooks.json'), 'utf-8')); + expect(userManifest.claude).toBeUndefined(); + expect(userManifest.codex).toBeUndefined(); + expect(userManifest.codebuddy).toHaveLength(1); + expect(userManifest.codebuddy[0].command).toContain('teamai-e2e-hook-marker'); + + for (const file of mainFiles()) { + const settings = JSON.parse(fs.readFileSync(file, 'utf-8')); + expect(settings.hooks.Stop.map((entry: { hooks: Array<{ command: string }> }) => entry.hooks[0].command)) + .toEqual(['echo teamai-e2e-hook-marker']); + expect(settings.hooks.SessionStart).toBeUndefined(); + } + for (const file of homeFiles()) { + const settings = fs.readFileSync(file, 'utf-8'); + expect(settings).toContain('hook-dispatch session-start'); + if (file.includes('.codebuddy')) { + expect(settings).toContain('teamai-e2e-hook-marker'); + expect(settings).toContain('$PWD'); + } else { + expect(settings).not.toContain('teamai-e2e-hook-marker'); + } + } expect(fs.existsSync(path.join(projectRoot, '.claude', 'settings.json'))).toBe(false); - const userSettings = fs.readFileSync(path.join(homeDir, '.claude', 'settings.json'), 'utf-8'); - expect(userSettings).toContain('teamai-e2e-hook-marker'); + expect(fs.existsSync(path.join(projectRoot, '.codebuddy', 'settings.json'))).toBe(false); }); - it('re-running inject is idempotent — no duplicate hook entries in the user settings file', async () => { - const before = fs.readFileSync(path.join(homeDir, '.claude', 'settings.json'), 'utf-8'); - const beforeCount = before.split('teamai-e2e-hook-marker').length - 1; + it('re-running inject is idempotent in both main-checkout and HOME team-hook files', async () => { + const files = [...mainFiles(), path.join(homeDir, '.codebuddy', 'settings.json')]; + const before = files.map((file) => fs.readFileSync(file, 'utf-8')); + for (const settings of before) expect(settings.split('teamai-e2e-hook-marker')).toHaveLength(2); const res = await runCLI(['hooks', 'inject'], { HOME: homeDir }, projectRoot); expect(res.code, res.output).toBe(0); - - const after = fs.readFileSync(path.join(homeDir, '.claude', 'settings.json'), 'utf-8'); - const afterCount = after.split('teamai-e2e-hook-marker').length - 1; - expect(afterCount).toBe(beforeCount); + expect(files.map((file) => fs.readFileSync(file, 'utf-8'))).toEqual(before); }); - it('remove cleans up the user HOME copy', async () => { + it('remove cleans up the main-checkout and HOME copies', async () => { const res = await runCLI(['hooks', 'remove'], { HOME: homeDir }, projectRoot); expect(res.code, res.output).toBe(0); - const userSettings = fs.readFileSync(path.join(homeDir, '.claude', 'settings.json'), 'utf-8'); - expect(userSettings).not.toContain('teamai-e2e-hook-marker'); + for (const file of [...mainFiles(), ...homeFiles()]) { + expect(fs.readFileSync(file, 'utf-8')).not.toContain('teamai-e2e-hook-marker'); + } + expect(fs.readFileSync(userConfigPath, 'utf-8')).toBe(userConfigBefore); }); }); diff --git a/src/__tests__/hooks-cmd.test.ts b/src/__tests__/hooks-cmd.test.ts index 9d8bb0d48..bbfc3bb98 100644 --- a/src/__tests__/hooks-cmd.test.ts +++ b/src/__tests__/hooks-cmd.test.ts @@ -714,7 +714,7 @@ describe('hooksRemove', () => { expect.any(String), [], expect.stringContaining('managed-hooks.json'), - { removeAll: true, scope: 'user', installedBaseDir: undefined, mainCheckout: null }, + { removeAll: true, scope: 'user', installedBaseDir: undefined, teamHookProjectRoot: undefined, mainCheckout: null }, ); expect(mockedLog.success).toHaveBeenCalledWith(expect.stringContaining('Hooks removed')); }); @@ -738,7 +738,7 @@ describe('hooksRemove', () => { '/home/testuser', [], expect.any(String), - { removeAll: true, scope: 'project', installedBaseDir: '/path/to/project', mainCheckout: null }, + { removeAll: true, scope: 'project', installedBaseDir: '/path/to/project', teamHookProjectRoot: '/path/to/project', mainCheckout: null }, ); const userManifest = mockedReconcile.mock.calls[0][3] as string; expect(userManifest).toContain('/home/testuser'); @@ -775,7 +775,7 @@ describe('hooksRemove', () => { '/path/to/project', [], expect.any(String), - { removeAll: true, scope: 'project', installedBaseDir: '/path/to/project', mainCheckout: null }, + { removeAll: true, scope: 'project', installedBaseDir: '/path/to/project', teamHookProjectRoot: undefined, mainCheckout: null }, ); }); diff --git a/src/hooks-cmd.ts b/src/hooks-cmd.ts index b7fcfee23..cd85529af 100644 --- a/src/hooks-cmd.ts +++ b/src/hooks-cmd.ts @@ -10,6 +10,7 @@ import { COPILOT_TOOL_ID, getManagedHooksPath, isAgentExcluded, + isSelfMode, resolveHookScope, resolveToolBaseDir, scopedToolPaths, @@ -277,6 +278,9 @@ export async function hooksRemove(_options: GlobalOptions): Promise { removeAll: true, scope: localConfig.scope, installedBaseDir: localConfig.scope === 'project' ? localConfig.projectRoot : undefined, + teamHookProjectRoot: localConfig.scope === 'project' && !isSelfMode(localConfig) + ? localConfig.projectRoot + : undefined, // The project's Claude and Codex team hooks live in the main checkout. mainCheckout: await resolveMainCheckoutHooks(localConfig), }); From b177c7a0586734b3d4b081f580475e655cc2de0d Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 07:10:27 +0200 Subject: [PATCH 07/13] test(hooks): retain main-checkout isolation with host guard (#955) --- .../hooks-project-isolation-issue373.test.ts | 20 ++++++++++++++++--- .../e2e/scope-isolation-issue85.test.ts | 4 +++- src/__tests__/hooks-reconcile-scope.test.ts | 8 ++++---- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/src/__tests__/e2e/hooks-project-isolation-issue373.test.ts b/src/__tests__/e2e/hooks-project-isolation-issue373.test.ts index f441780dd..12c101be0 100644 --- a/src/__tests__/e2e/hooks-project-isolation-issue373.test.ts +++ b/src/__tests__/e2e/hooks-project-isolation-issue373.test.ts @@ -4,6 +4,7 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { fileURLToPath } from 'node:url'; +import { CLAUDE_HOOK_OTHER_HOST_SKIP } from '../../hooks.js'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); const CLI = path.join(ROOT, 'dist', 'index.js'); @@ -31,6 +32,8 @@ describe('issue #373 project hook isolation (real CLI)', () => { let worktreeA: string; type Settings = { hooks: { SessionStart?: Array; Stop: Array<{ description?: string; hooks: Array<{ command: string }> }> } }; + const expectedCommand = (file: string, command: string): string => + file.includes('.claude') ? `${CLAUDE_HOOK_OTHER_HOST_SKIP}${command}` : command; const readSettings = (file: string): Settings => JSON.parse(fs.readFileSync(file, 'utf8')) as Settings; const mainFiles = (project: string): string[] => [ path.join(project, '.claude', 'settings.local.json'), @@ -104,7 +107,7 @@ describe('issue #373 project hook isolation (real CLI)', () => { for (const [project, command] of [[projectA, 'echo A'], [projectB, 'echo B']]) { for (const file of mainFiles(project)) { const settings = readSettings(file); - expect(settings.hooks.Stop.map((entry) => entry.hooks[0].command)).toEqual([command]); + expect(settings.hooks.Stop.map((entry) => entry.hooks[0].command)).toEqual([expectedCommand(file, command)]); expect(settings.hooks.SessionStart).toBeUndefined(); } } @@ -132,7 +135,7 @@ describe('issue #373 project hook isolation (real CLI)', () => { expect(removed.code, removed.output).toBe(0); for (const file of mainFiles(projectA)) expect(readSettings(file).hooks.Stop ?? []).toEqual([]); for (const file of mainFiles(projectB)) { - expect(readSettings(file).hooks.Stop.map((entry) => entry.hooks[0].command)).toEqual(['echo B']); + expect(readSettings(file).hooks.Stop.map((entry) => entry.hooks[0].command)).toEqual([expectedCommand(file, 'echo B')]); } const remaining = readSettings(settingsPath).hooks.Stop.filter((entry) => entry.description?.startsWith('[teamai:hook:')); expect(remaining.map((entry) => entry.hooks[0].command)).toEqual([commandB]); @@ -153,8 +156,19 @@ describe('issue #373 project hook isolation (real CLI)', () => { for (const file of mainFiles(worktreeA)) expect(fs.existsSync(file)).toBe(false); for (const file of mainFiles(projectA)) { const [command] = readSettings(file).hooks.Stop.map((entry) => entry.hooks[0].command); - expect(command).toBe('echo A'); + expect(command).toBe(expectedCommand(file, 'echo A')); + expect(command).not.toContain('$PWD'); expect(execFileSync('sh', ['-c', command], { cwd: worktreeA, encoding: 'utf8' })).toBe('A\n'); + if (file.includes('.claude')) { + // The main-checkout layout preserves #950's other-host check without a cwd gate. + const cursorFile = path.join(home, '.cursor', 'hooks.json'); + const env = { ...process.env, HOME: home, CURSOR_VERSION: 'test', CURSOR_PROJECT_DIR: '', COPILOT_PROJECT_DIR: '' }; + fs.mkdirSync(path.dirname(cursorFile), { recursive: true }); + fs.writeFileSync(cursorFile, JSON.stringify({ hooks: { stop: [{ command: 'teamai hook-dispatch stop --tool cursor' }] } })); + expect(execFileSync('sh', ['-c', command], { cwd: worktreeA, env, encoding: 'utf8' })).toBe(''); + fs.rmSync(cursorFile); + expect(execFileSync('sh', ['-c', command], { cwd: worktreeA, env, encoding: 'utf8' })).toBe('A\n'); + } } }); }); diff --git a/src/__tests__/e2e/scope-isolation-issue85.test.ts b/src/__tests__/e2e/scope-isolation-issue85.test.ts index 7e1d1e02c..8cdbf4f25 100644 --- a/src/__tests__/e2e/scope-isolation-issue85.test.ts +++ b/src/__tests__/e2e/scope-isolation-issue85.test.ts @@ -4,6 +4,7 @@ import path from 'node:path'; import fs from 'node:fs'; import os from 'node:os'; import { fileURLToPath } from 'node:url'; +import { CLAUDE_HOOK_OTHER_HOST_SKIP } from '../../hooks.js'; // ─── Issue #85 end-to-end: remaining scope-isolation gaps ────────────── // @@ -195,7 +196,8 @@ describe('issue #85 remaining scope-isolation gaps (e2e)', () => { for (const file of mainFiles()) { const settings = JSON.parse(fs.readFileSync(file, 'utf-8')); expect(settings.hooks.Stop.map((entry: { hooks: Array<{ command: string }> }) => entry.hooks[0].command)) - .toEqual(['echo teamai-e2e-hook-marker']); + .toEqual([`${file.includes('.claude') ? CLAUDE_HOOK_OTHER_HOST_SKIP : ''}echo teamai-e2e-hook-marker`]); + expect(fs.readFileSync(file, 'utf-8')).not.toContain('$PWD'); expect(settings.hooks.SessionStart).toBeUndefined(); } for (const file of homeFiles()) { diff --git a/src/__tests__/hooks-reconcile-scope.test.ts b/src/__tests__/hooks-reconcile-scope.test.ts index 57f1c8173..8e37ec258 100644 --- a/src/__tests__/hooks-reconcile-scope.test.ts +++ b/src/__tests__/hooks-reconcile-scope.test.ts @@ -15,7 +15,7 @@ vi.mock('../utils/logger.js', () => ({ })); import { resolveAnchors, listWorktrees } from '../utils/git.js'; -import { reconcileTeamHooksForConfig } from '../hooks.js'; +import { CLAUDE_HOOK_OTHER_HOST_SKIP, reconcileTeamHooksForConfig } from '../hooks.js'; import type { LocalConfig, TeamaiConfig } from '../types.js'; let project: string; @@ -114,7 +114,7 @@ hooks: const claudeTeam = await claudeLocal(); expect(claudeTeam.hooks.Stop).toHaveLength(1); expect(claudeTeam.hooks.Stop[0].description).toBe('[teamai:hook:lint] run lint at stop'); - expect(claudeTeam.hooks.Stop[0].hooks[0].command).toBe('npm run lint'); + expect(claudeTeam.hooks.Stop[0].hooks[0].command).toBe(`${CLAUDE_HOOK_OTHER_HOST_SKIP}npm run lint`); expect(claudeTeam.hooks.SessionStart).toBeUndefined(); // Every other tool: built-in + gated team hook in HOME. @@ -177,9 +177,9 @@ hooks: expect(m.cursor).toHaveLength(2); // Claude's team hooks live in each project's own checkout. - expect((await claudeLocal()).hooks.Stop.map((e) => e.hooks[0].command)).toEqual(['echo project-a']); + expect((await claudeLocal()).hooks.Stop.map((e) => e.hooks[0].command)).toEqual([`${CLAUDE_HOOK_OTHER_HOST_SKIP}echo project-a`]); const claudeB = await fse.readJson(path.join(projectB, '.claude', 'settings.local.json')); - expect(claudeB.hooks.Stop.map((e: { hooks: Array<{ command: string }> }) => e.hooks[0].command)).toEqual(['echo project-b']); + expect(claudeB.hooks.Stop.map((e: { hooks: Array<{ command: string }> }) => e.hooks[0].command)).toEqual([`${CLAUDE_HOOK_OTHER_HOST_SKIP}echo project-b`]); } finally { await fse.remove(projectB); await fse.remove(repoB); From 9ae91686436f92ca79c43e6df1ac7df4cf206741 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 08:14:15 +0200 Subject: [PATCH 08/13] fix(hooks): track exact Codex hook ownership (#955) --- docs/designs/data-directory-layout.md | 1 + docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- skill-data/core/references/troubleshooting.md | 5 +- src/__tests__/codex-trust.test.ts | 72 ++++++++++++++- src/__tests__/hooks-reconcile-scope.test.ts | 26 +++++- src/codex-trust.ts | 24 ++--- src/hooks.ts | 90 +++++++++++++------ 8 files changed, 177 insertions(+), 45 deletions(-) diff --git a/docs/designs/data-directory-layout.md b/docs/designs/data-directory-layout.md index c966e40d9..c558d4194 100644 --- a/docs/designs/data-directory-layout.md +++ b/docs/designs/data-directory-layout.md @@ -497,6 +497,7 @@ every checkout, so that is where they live now: ├── pending-learnings/ pendingLearningsDir → /pending-learnings ├── managed-main-checkout-hooks.json team hooks teamai wrote ungated into the main checkout's Claude Code / Codex │ settings, shared by every checkout (#955; the built-in hooks stay in HOME) +│ Codex records event, matcher-group position and complete rendered entry for exact ownership/trust └── workspaces// ├── managed-mcp.json managedMcpManifestPath, one per checkout; Copilot placement is true for bare, false for keyed, absent when unproven ├── managed-mcp-files.json resolvedMcpFilesPath: project MCP configs teamai may have written a resolved ${VAR} to, and whether diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 95a0cb60a..5e37601cc 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1926,7 +1926,7 @@ On Windows, the built-in hook dispatch commands that shell out through bash (e.g Cursor also loads `~/.claude/settings.json`, and Copilot CLI loads a trusted project's `.claude/settings.json` (self mode writes hooks there; Copilot does not load `~/.claude/settings.json`). `hook-dispatch --tool claude` exits only when that other host's own teamai hooks are on disk: `~/.cursor/hooks.json` or `$CURSOR_PROJECT_DIR/.cursor/hooks.json` contains `--tool cursor`, or `$COPILOT_PROJECT_DIR/.github/hooks/teamai.json` contains `--tool copilot`. Team hook commands written for `claude` use the same check. A setup with only Claude keeps running inside Cursor, because there is no second copy. `COPILOT_CLI` is not a signal: Copilot sets it on every subprocess, including a Claude session started from its shell. Claude Code sets neither `CURSOR_VERSION` nor `COPILOT_PROJECT_DIR`. Run `teamai pull` or `teamai hooks inject` again so an already installed team hook picks up the guard. -> **Codex hook trust** — Codex (the OpenAI / ChatGPT Codex app, tool id `codex`) runs a non-managed hook only once it is trusted, and skips an untrusted or changed one without a word; it reads a project's `.codex/` only when the project is trusted. So after every write of a Codex hooks file (`init`, every `pull` including the session-start one, `teamai hooks inject`) teamai trusts exactly the hooks it wrote, through `codex app-server` — the same call Codex's `/hooks` trust prompt makes. Your own hooks in the same file are left alone. In a project, teamai also trusts the main checkout when Codex has to read teamai's hooks or MCP servers from its `.codex/`; a project you marked untrusted in Codex stays so, and teamai says so. Trust written by a session-start pull applies from the next Codex session: the running one already loaded its hooks. A linked worktree reads the main checkout's `.codex/hooks.json` only once it has a `.codex/` directory, which the Codex session-start hook creates, so a new worktree gets the team hooks from its second Codex session; the built-in hooks live in `~/.codex/hooks.json` and run from the first. To trust them yourself, set `codexTrustEnabled: false` in `config.yaml`. `init` and `hooks inject` print a reminder to trust them in `/hooks` or Settings → Hooks when `codex` is absent or its app-server fails. An interactive pull warns on app-server failure and stays quiet when `codex` is absent; silent pulls record the result in the debug log. `teamai doctor` asks Codex which teamai hooks it will not run and names them. +> **Codex hook trust** — Codex (the OpenAI / ChatGPT Codex app, tool id `codex`) runs a non-managed hook only once it is trusted, and skips an untrusted or changed one without a word; it reads a project's `.codex/` only when the project is trusted. So after every write of a Codex hooks file (`init`, every `pull` including the session-start one, `teamai hooks inject`) teamai trusts exactly the hooks it wrote, through `codex app-server` — the same call Codex's `/hooks` trust prompt makes. Your own hooks in the same file are left alone, even when their commands equal a team hook. Codex ownership records include the event, position and complete generated entry; trust selects that exact Codex key. Unrecorded or ambiguous legacy team-hook copies are preserved. In a project, teamai also trusts the main checkout when Codex has to read teamai's hooks or MCP servers from its `.codex/`; a project you marked untrusted in Codex stays so, and teamai says so. Trust written by a session-start pull applies from the next Codex session: the running one already loaded its hooks. A linked worktree reads the main checkout's `.codex/hooks.json` only once it has a `.codex/` directory, which the Codex session-start hook creates, so a new worktree gets the team hooks from its second Codex session; the built-in hooks live in `~/.codex/hooks.json` and run from the first. To trust them yourself, set `codexTrustEnabled: false` in `config.yaml`. `init` and `hooks inject` print a reminder to trust them in `/hooks` or Settings → Hooks when `codex` is absent or its app-server fails. An interactive pull warns on app-server failure and stays quiet when `codex` is absent; silent pulls record the result in the debug log. `teamai doctor` asks Codex which teamai hooks it will not run and names them. ### Team Hooks Declaration diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 1a42c29c0..4a6517283 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1791,7 +1791,7 @@ inject 和 remove 只会操作你实际已安装的工具(即 `~/./` 根 Cursor 也会加载 `~/.claude/settings.json`。Copilot CLI 会加载受信任项目里的 `.claude/settings.json`(self mode 把 hook 写在项目里;Copilot 不加载 `~/.claude/settings.json`)。只有另一边的 teamai hook 已经在磁盘上时,`hook-dispatch --tool claude` 才会退出:`~/.cursor/hooks.json` 或 `$CURSOR_PROJECT_DIR/.cursor/hooks.json` 含有 `--tool cursor`,或 `$COPILOT_PROJECT_DIR/.github/hooks/teamai.json` 含有 `--tool copilot`。写给 `claude` 的团队 hook 命令用同一判断。只启用了 Claude 时,Cursor 里这份 hook 照常运行,因为没有第二份可以接替。`COPILOT_CLI` 不能当信号:Copilot 会给每个子进程设置它,包括从它的 shell 里启动的 Claude。Claude Code 不会设置 `CURSOR_VERSION` 或 `COPILOT_PROJECT_DIR`。已经装好的团队 hook 需要再跑一次 `teamai pull` 或 `teamai hooks inject`,才会带上这个判断。 -> **Codex hook 信任** — Codex(OpenAI / ChatGPT Codex 应用,工具 id 为 `codex`)只运行已信任的非托管 hook,未信任或已变更的 hook 会被静默跳过;且只有项目被信任时才读取其 `.codex/`。因此每次写入 Codex hooks 文件后(`init`、每次 `pull`(含 SessionStart 触发的 pull)、`teamai hooks inject`),teamai 都会通过 `codex app-server` 信任它写入的那些 hook——与 Codex `/hooks` 信任提示调用的是同一接口。同一文件里你自己的 hook 不受影响。在项目中,当 Codex 需要从主 checkout 的 `.codex/` 读取 teamai 的 hooks 或 MCP servers 时,teamai 也会信任该主 checkout;你在 Codex 中标记为不信任的项目保持不变,teamai 会提示。SessionStart 触发的 pull 写入的信任从下一个 Codex 会话起生效:当前会话已加载了它的 hooks。linked worktree 只有在存在 `.codex/` 目录时才读取主 checkout 的 `.codex/hooks.json`,该目录由 Codex 的 SessionStart hook 创建,因此新 worktree 从第二个 Codex 会话起获得团队 hooks;内置 hooks 位于 `~/.codex/hooks.json`,从第一个会话起就运行。若要自行信任,在 `config.yaml` 中设置 `codexTrustEnabled: false`。PATH 中没有 `codex` 或 app-server 失败时,`init` 和 `hooks inject` 会提示你在 `/hooks` 或 Settings → Hooks 中信任。交互式 pull 仅在 app-server 失败时警告,缺少 `codex` 时保持静默;silent pull 将结果记录在 debug 日志中。`teamai doctor` 会向 Codex 查询哪些 teamai hooks 不会运行并逐一列出。 +> **Codex hook 信任** — Codex(OpenAI / ChatGPT Codex 应用,工具 id 为 `codex`)只运行已信任的非托管 hook,未信任或已变更的 hook 会被静默跳过;且只有项目被信任时才读取其 `.codex/`。因此每次写入 Codex hooks 文件后(`init`、每次 `pull`(含 SessionStart 触发的 pull)、`teamai hooks inject`),teamai 都会通过 `codex app-server` 信任它写入的那些 hook——与 Codex `/hooks` 信任提示调用的是同一接口。同一文件里你自己的 hook 不受影响,即使命令与团队 hook 相同。Codex 所有权记录包含事件、位置和完整生成条目,信任操作只选择对应的 Codex key。没有所有权记录或无法区分的旧团队 hook 副本会保留。在项目中,当 Codex 需要从主 checkout 的 `.codex/` 读取 teamai 的 hooks 或 MCP servers 时,teamai 也会信任该主 checkout;你在 Codex 中标记为不信任的项目保持不变,teamai 会提示。SessionStart 触发的 pull 写入的信任从下一个 Codex 会话起生效:当前会话已加载了它的 hooks。linked worktree 只有在存在 `.codex/` 目录时才读取主 checkout 的 `.codex/hooks.json`,该目录由 Codex 的 SessionStart hook 创建,因此新 worktree 从第二个 Codex 会话起获得团队 hooks;内置 hooks 位于 `~/.codex/hooks.json`,从第一个会话起就运行。若要自行信任,在 `config.yaml` 中设置 `codexTrustEnabled: false`。PATH 中没有 `codex` 或 app-server 失败时,`init` 和 `hooks inject` 会提示你在 `/hooks` 或 Settings → Hooks 中信任。交互式 pull 仅在 app-server 失败时警告,缺少 `codex` 时保持静默;silent pull 将结果记录在 debug 日志中。`teamai doctor` 会向 Codex 查询哪些 teamai hooks 不会运行并逐一列出。 ### 团队 Hooks 声明 diff --git a/skill-data/core/references/troubleshooting.md b/skill-data/core/references/troubleshooting.md index ed8291fec..d4fdefacf 100644 --- a/skill-data/core/references/troubleshooting.md +++ b/skill-data/core/references/troubleshooting.md @@ -172,7 +172,10 @@ from the next Codex session. `teamai doctor` names any teamai hook Codex will no run. Then: run `teamai pull`; if `codex` is not on PATH or `codexTrustEnabled: false` is set in `config.yaml`, guide the user to trust the teamai hooks in Codex `/hooks`, then reopen a session. A new linked worktree gets the team hooks from its second -Codex session (the first creates its `.codex/`). +Codex session (the first creates its `.codex/`). Member hooks with the same command +are preserved and remain untouched by automatic trust. Codex ownership uses the +recorded event, position and complete entry; unrecorded or ambiguous legacy team-hook copies +are preserved rather than claimed by command. ### Cursor diff --git a/src/__tests__/codex-trust.test.ts b/src/__tests__/codex-trust.test.ts index 666f01709..51f078f0f 100644 --- a/src/__tests__/codex-trust.test.ts +++ b/src/__tests__/codex-trust.test.ts @@ -102,6 +102,76 @@ afterEach(async () => { await fse.remove(fakeBin); }); +describe('Codex hook ownership', () => { + it('preserves member hooks on the first project reconcile and through team updates/removal', async () => { + await writeYaml(LINT_HOOK); + const project = await fse.realpath(await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-codex-owner-project-'))); + const config = userConfig({ scope: 'project', projectRoot: project }); + const file = path.join(project, '.codex', 'hooks.json'); + const memberGroups = [ + { hooks: [{ type: 'command', command: 'npm run lint' }] }, + { matcher: 'Bash', hooks: [{ type: 'command', command: 'npm run lint', timeout: 17 }] }, + ]; + try { + await fse.outputJson(file, { hooks: { PreToolUse: memberGroups, Stop: [memberGroups[0]] } }); + const memberKeys = (await codexEntries(file)).map((e) => e.key); + + await writeAndTrust(config); + + expect((await fse.readJson(file)).hooks.PreToolUse.slice(0, 2)).toEqual(memberGroups); + expect((await fse.readJson(file)).hooks.Stop).toEqual([memberGroups[0]]); + expect(trustedKeys().some((key) => memberKeys.includes(key))).toBe(false); + await writeYaml(LINT_HOOK.replace('npm run lint', 'npm run lint:fix')); + await writeAndTrust(config); + expect((await fse.readJson(file)).hooks.PreToolUse.slice(0, 2)).toEqual(memberGroups); + await writeAndTrust(config, { removeAll: true }); + expect((await fse.readJson(file)).hooks.PreToolUse).toEqual(memberGroups); + expect((await fse.readJson(file)).hooks.Stop).toEqual([memberGroups[0]]); + } finally { + await fse.remove(project); + } + }); + + it.each(['Stop', 'PreToolUse'])('preserves and never trusts member hooks sharing a team command under %s', async (event) => { + await writeYaml(LINT_HOOK); + const file = path.join(codexHome(), 'hooks.json'); + const memberGroups = [ + { hooks: [{ type: 'command', command: 'npm run lint' }] }, + { matcher: 'Bash', hooks: [{ type: 'command', command: 'npm run lint', timeout: 17, additionalContextLimit: 0 }] }, + ]; + await fse.writeJson(file, { hooks: { [event]: memberGroups } }); + const memberKeys = (await codexEntries(file)).map((e) => e.key); + + await writeAndTrust(userConfig()); + + const teamKeys = (await codexEntries(file)).filter((e) => !memberKeys.includes(e.key)).map((e) => e.key); + expect(trustedKeys().sort()).toEqual(teamKeys.sort()); + expect((await fse.readJson(file)).hooks[event].slice(0, 2)).toEqual(memberGroups); + await writeAndTrust(userConfig()); + expect((await fse.readJson(file)).hooks[event].slice(0, 2)).toEqual(memberGroups); + expect(trustedKeys().some((key) => memberKeys.includes(key))).toBe(false); + await writeAndTrust(userConfig(), { removeAll: true }); + expect((await fse.readJson(file)).hooks[event]).toEqual(memberGroups); + }); + + it('keeps ambiguous legacy entries instead of claiming every identical command', async () => { + await writeYaml(LINT_HOOK); + const file = path.join(codexHome(), 'hooks.json'); + const member = { hooks: [{ type: 'command', command: 'npm run lint' }] }; + await fse.writeJson(file, { hooks: { PreToolUse: [member, member] } }); + await fse.outputJson(path.join(home, '.teamai', 'managed-hooks.json'), { + codex: [{ id: 'lint', event: 'PreToolUse', command: 'npm run lint' }], + }); + + await writeAndTrust(userConfig()); + + expect((await fse.readJson(file)).hooks.PreToolUse.slice(0, 2)).toEqual([member, member]); + expect(trustedKeys()).not.toContain(`${file}:pre_tool_use:0:0`); + expect(trustedKeys()).not.toContain(`${file}:pre_tool_use:1:0`); + }); + +}); + describe('Codex hook trust — user scope', () => { it('trusts every hook teamai wrote and leaves the member\'s own hook alone', async () => { await writeYaml(LINT_HOOK); @@ -133,7 +203,7 @@ describe('Codex hook trust — user scope', () => { it('fails actionably when Codex does not list a requested hook', async () => { const file = path.join(codexHome(), 'hooks.json'); - const result = await trustCodexHooks({ codexHome: codexHome(), cwd: home, hooks: [{ file, command: 'missing' }] }); + const result = await trustCodexHooks({ codexHome: codexHome(), cwd: home, hooks: [{ file, command: 'missing', key: `${file}:stop:0:0` }] }); expect(result).toEqual({ kind: 'failed', reason: expect.stringMatching(/hooks\/list.*missing.*not loaded.*teamai pull/) }); }); diff --git a/src/__tests__/hooks-reconcile-scope.test.ts b/src/__tests__/hooks-reconcile-scope.test.ts index 8e37ec258..868aea072 100644 --- a/src/__tests__/hooks-reconcile-scope.test.ts +++ b/src/__tests__/hooks-reconcile-scope.test.ts @@ -482,6 +482,27 @@ builtin: describe('reconcileTeamHooksForConfig — team hooks in the main checkout', () => { const STOP_LINT = 'hooks:\n - id: lint\n description: lint\n event: Stop\n command: npm run lint\n'; + it('keeps one HOME-gated internal Codex hook per project when their pulls alternate', async () => { + await writeYaml(STOP_LINT); + const other = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-internal-other-')); + const file = path.join(home, '.codex-internal', 'hooks.json'); + await fse.ensureDir(path.dirname(file)); + const internal = { toolPaths: { 'codex-internal': { settings: '.codex-internal/hooks.json' } } } as unknown as TeamaiConfig; + try { + for (const root of [project, other, project, other]) { + await reconcileTeamHooksForConfig(internal, { ...localConfig(), projectRoot: root }); + } + const commands = (await fse.readJson(file)).hooks.Stop + .map((entry: { hooks: Array<{ command: string }> }) => entry.hooks[0].command) + .filter((command: string) => command.includes('$PWD')); + expect(commands).toHaveLength(2); + expect(commands.filter((command: string) => command.includes(project))).toHaveLength(1); + expect(commands.filter((command: string) => command.includes(other))).toHaveLength(1); + } finally { + await fse.remove(other); + } + }); + async function mainWithWorktree(): Promise<{ main: string; worktree: string }> { const main = await fse.realpath(project); const worktree = path.join(await fse.realpath(os.tmpdir()), `teamai-recon-wt-${path.basename(main)}`); @@ -543,7 +564,7 @@ describe('reconcileTeamHooksForConfig — team hooks in the main checkout', () = expect(await fse.pathExists(path.join(project, '.claude', 'settings.local.json'))).toBe(false); }); - it('replaces a copy a pre-#370 CLI left in the main checkout\'s Codex file instead of duplicating it', async () => { + it('replaces a recorded legacy copy in the main checkout\'s Codex file instead of duplicating it', async () => { await writeYaml(STOP_LINT); await fse.outputJson(path.join(project, '.codex', 'hooks.json'), { hooks: { @@ -551,6 +572,9 @@ describe('reconcileTeamHooksForConfig — team hooks in the main checkout', () = Stop: [{ hooks: [{ type: 'command', command: 'npm run lint' }] }], }, }); + await fse.outputJson(path.join(project, '.teamai', 'managed-main-checkout-hooks.json'), { + codex: [{ id: 'lint', event: 'Stop', command: 'npm run lint' }], + }); await reconcileTeamHooksForConfig(teamConfig, localConfig()); diff --git a/src/codex-trust.ts b/src/codex-trust.ts index 9de451897..ecb6375af 100644 --- a/src/codex-trust.ts +++ b/src/codex-trust.ts @@ -30,8 +30,8 @@ export interface CodexHookTrustRequest { codexHome: string; /** Directory whose hook layers are listed. */ cwd: string; - /** The hooks teamai wrote, by file and command. A member's own hook is left alone. */ - hooks: Array<{ file: string; command: string }>; + /** Exact generated entries, with Codex's file/event/position key. */ + hooks: Array<{ file: string; command: string; key: string }>; /** Project to trust first, so Codex reads its `.codex/` layer (realpath). */ project?: string; } @@ -211,8 +211,8 @@ export async function trustCodexProject(req: { codexHome: string; project: strin }); } -function hookId(file: string, command: string): string { - return `${canonical(file)}\0${command}`; +function hookId(file: string, command: string, key: string): string { + return `${canonical(file)}\0${key}\0${command}`; } async function listHooks(server: AppServer, cwd: string): Promise { @@ -222,18 +222,18 @@ async function listHooks(server: AppServer, cwd: string): Promise { } /** - * Trust exactly the hooks teamai wrote: those Codex lists for `cwd` whose file - * and command are in `hooks`. Trusts the project first when one is given, + * Trust exactly the hooks teamai wrote: those Codex lists for `cwd` whose key, + * file and command are in `hooks`. Trusts the project first when one is given, * since an untrusted project's hook layer is not read. */ export async function trustCodexHooks(req: CodexHookTrustRequest): Promise { - const wanted = new Set(req.hooks.map((h) => hookId(h.file, h.command))); + const wanted = new Set(req.hooks.map((h) => hookId(h.file, h.command, h.key))); const project = req.project ? canonical(req.project) : undefined; return withAppServer(req.codexHome, async (server): Promise => { const level = project ? await ensureProjectTrusted(server, project) : undefined; const listed = await listHooks(server, req.cwd); - const loaded = new Set(listed.map((h) => hookId(h.sourcePath, h.command))); - const missing = req.hooks.filter((h) => !loaded.has(hookId(h.file, h.command))); + const loaded = new Set(listed.map((h) => hookId(h.sourcePath, h.command, h.key))); + const missing = req.hooks.filter((h) => !loaded.has(hookId(h.file, h.command, h.key))); if (missing.length > 0 && level !== 'untrusted') { return { kind: 'failed', @@ -241,7 +241,7 @@ export async function trustCodexHooks(req: CodexHookTrustRequest): Promise - h.trustStatus !== 'trusted' && wanted.has(hookId(h.sourcePath, h.command))); + h.trustStatus !== 'trusted' && wanted.has(hookId(h.sourcePath, h.command, h.key))); if (toTrust.length > 0) { await batchWrite( server, @@ -261,11 +261,11 @@ export async function trustCodexHooks(req: CodexHookTrustRequest): Promise): Promise { return withAppServer(req.codexHome, async (server): Promise => { - const listed = new Map((await listHooks(server, req.cwd)).map((h) => [hookId(h.sourcePath, h.command), h.trustStatus])); + const listed = new Map((await listHooks(server, req.cwd)).map((h) => [hookId(h.sourcePath, h.command, h.key), h.trustStatus])); return { kind: 'listed', notTrusted: req.hooks - .map((h) => ({ ...h, status: listed.get(hookId(h.file, h.command)) ?? 'not loaded' })) + .map((h) => ({ file: h.file, command: h.command, status: listed.get(hookId(h.file, h.command, h.key)) ?? 'not loaded' })) .filter((h) => h.status !== 'trusted'), }; }); diff --git a/src/hooks.ts b/src/hooks.ts index ffc196115..c81b723e8 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -1,6 +1,7 @@ import { CODEX_TOOL_IDS } from './utils/tool-names.js'; import path from 'node:path'; import { createHash } from 'node:crypto'; +import { isDeepStrictEqual } from 'node:util'; import { realpathSync } from 'node:fs'; import { rm, stat } from 'node:fs/promises'; import { readJson, writeJson, readFileSafe, writeFile, expandHome, ensureDir, pathExists } from './utils/fs.js'; @@ -308,9 +309,8 @@ export interface ReconcileHooksOptions { teamHookProjectRoot?: string; /** * Write the team hooks alone: the built-ins are not desired here, and any - * built-in entry found in the file is removed. An existing entry running a - * desired team command counts as teamai's, so a copy an older layout left in - * the same file is replaced instead of duplicated. + * built-in entry found in the file is removed. Team entries are owned only + * when the manifest records them; a matching command alone is not ownership. */ teamOnly?: boolean; } @@ -321,6 +321,9 @@ export interface ManagedHookRecord { event: string; matcher?: string; command: string; + /** Codex matcher-group position and complete definition written by reconciliation. */ + codexEntryIndex?: number; + codexEntry?: CodexHookMatcher; } /** ~/.teamai/managed-hooks.json — team hooks injected per tool. */ @@ -950,16 +953,20 @@ async function reconcileCodexFormat( tool: string, teamDefs: HookDef[], opts: ReconcileHooksOptions, - priorTeamCommands: Set, -): Promise { + priorRecords: ManagedHookRecord[], +): Promise { const expanded = expandHome(hooksPath); await ensureDir(path.dirname(expanded)); const hooksJson: CodexHooksJson = (await readJson(expanded)) ?? {}; if (!hooksJson.hooks) hooksJson.hooks = {}; - const isManaged = (entry: CodexHookMatcher): boolean => { + const isManaged = (event: string, index: number, entries: CodexHookMatcher[]): boolean => { + const entry = entries[index]; const cmd = entry.hooks?.[0]?.command ?? ''; - return TEAMAI_COMMAND_MARKERS.some((marker) => cmd.includes(marker)) || priorTeamCommands.has(cmd); + return TEAMAI_COMMAND_MARKERS.some((marker) => cmd.includes(marker)) + || priorRecords.some((record) => tool === CODEX_TOOL_ID + ? ownsCodexEntry(record, event, index, entries) + : record.command === cmd); }; const defs = opts.removeAll ? [] : desiredDefs(tool, teamDefs, opts); @@ -967,11 +974,24 @@ async function reconcileCodexFormat( const events = [...eventOrder, ...Object.keys(hooksJson.hooks).filter((e) => !eventOrder.includes(e))]; let changed = false; + const records: ManagedHookRecord[] = []; for (const event of events) { const existing = hooksJson.hooks[event] ?? []; - const untouched = existing.filter((e) => !isManaged(e)); - const desiredEntries = defs.filter((d) => d.event === event).map(toCodexEntry); + const untouched = existing.filter((_, index) => !isManaged(event, index, existing)); + const eventDefs = defs.filter((d) => d.event === event); + const desiredEntries = eventDefs.map(toCodexEntry); const newArr = [...untouched, ...desiredEntries]; + eventDefs.forEach((def, index) => { + if (def.source !== 'team') return; + records.push({ + id: def.key, event, command: def.command, + ...(def.matcher && def.matcher !== '*' ? { matcher: def.matcher } : {}), + ...(tool === CODEX_TOOL_ID ? { + codexEntryIndex: untouched.length + index, + codexEntry: desiredEntries[index], + } : {}), + }); + }); if (JSON.stringify(existing) !== JSON.stringify(newArr)) { hooksJson.hooks[event] = newArr; changed = true; @@ -984,6 +1004,21 @@ async function reconcileCodexFormat( } else { log.debug(`teamai hooks already up-to-date in ${hooksPath}`); } + return records; +} + +/** Legacy records identify only an unambiguous, exact default entry under their event. */ +function ownsCodexEntry(record: ManagedHookRecord, event: string, index: number, entries: CodexHookMatcher[]): boolean { + if (record.event !== event) return false; + const entry = entries[index]; + if (record.codexEntry) { + return record.codexEntryIndex === index && isDeepStrictEqual(entry, record.codexEntry); + } + const legacy = { + ...(record.matcher ? { matcher: record.matcher } : {}), + hooks: [{ type: 'command', command: record.command }], + }; + return isDeepStrictEqual(entry, legacy) && entries.filter((candidate) => isDeepStrictEqual(candidate, legacy)).length === 1; } // ─── ZCode (~/.zcode/cli/config.json) reconcile ───────────── @@ -1274,18 +1309,16 @@ export async function reconcileHooks( : allPriorRecords; const scopedDefs = scopedTeamDefs(teamDefs, opts.teamHookProjectRoot, tool); const desiredTeamCommands = new Set(scopedDefs.filter((d) => !d.tools || d.tools.includes(tool)).map((d) => d.command)); - const priorTeamCommands = new Set([ - ...priorRecords.map((r) => r.command), - ...(opts.teamOnly ? desiredTeamCommands : []), - ]); + const priorTeamCommands = new Set(priorRecords.map((r) => r.command)); const format = detectFormat(tool); + let codexRecords: ManagedHookRecord[] | undefined; if (format === 'cursor') { await reconcileCursorFormat(settingsPath, tool, scopedDefs, opts, priorTeamCommands); } else if (format === 'copilot') { await reconcileCopilotFormat(settingsPath, tool, scopedDefs, opts, priorTeamCommands); } else if (format === 'codex') { - await reconcileCodexFormat(settingsPath, tool, scopedDefs, opts, priorTeamCommands); + codexRecords = await reconcileCodexFormat(settingsPath, tool, scopedDefs, opts, priorRecords); } else if (format === 'zcode') { await reconcileZcodeFormat(settingsPath, tool, scopedDefs, opts, priorTeamCommands); } else { @@ -1299,7 +1332,7 @@ export async function reconcileHooks( // Update the manifest's team-hook index for this tool (when manifest is active). if (opts.manifestPath && manifest) { - const records = manifestRecordsForTool(teamDefs, tool, !!opts.removeAll, opts.teamHookProjectRoot); + const records = codexRecords ?? manifestRecordsForTool(teamDefs, tool, !!opts.removeAll, opts.teamHookProjectRoot); const prev = manifest[tool] ?? []; const retained = opts.teamHookProjectRoot ? prev.filter((r) => !isGatedForProject(r.command, opts.teamHookProjectRoot!)) @@ -1930,8 +1963,8 @@ interface CodexTrustTargets { codexHome: string; /** The checkout whose hook layers are listed (HOME in user scope). */ cwd: string; - /** Every teamai hook in the Codex hook files of this scope, by file and command. */ - hooks: Array<{ file: string; command: string }>; + /** Exact generated Codex entries, including Codex's file/event/position key. */ + hooks: Array<{ file: string; command: string; key: string }>; /** The main checkout, when Codex has to read its `.codex/` layer. */ project?: string; /** The files whose bytes a trust pass depends on. */ @@ -1940,19 +1973,20 @@ interface CodexTrustTargets { mcpRecords: unknown[]; } -/** The teamai entries of one Codex hooks file: built-ins by exact rendered command, team hooks by manifest. */ -async function teamaiCodexHooks(file: string, manifestPath: string): Promise> { +/** Select recorded team entries, not every occurrence of a managed command. */ +async function teamaiCodexHooks(file: string, manifestPath: string): Promise { const json = await readJson(file); if (!json?.hooks) return []; - const teamCommands = new Set(((await readManifest(manifestPath))[CODEX_TOOL_ID] ?? []).map((r) => r.command)); - const builtinCommands = new Set(builtinHookDefs(CODEX_TOOL_ID).flatMap((def) => - toCodexEntry(def).hooks.map((hook) => hook.command))); - return Object.values(json.hooks) - .flatMap((groups) => (groups ?? []).flatMap((group) => group.hooks ?? [])) - .map((hook) => hook.command) - .filter((command) => typeof command === 'string' - && (builtinCommands.has(command) || teamCommands.has(command))) - .map((command) => ({ file, command })); + const records = (await readManifest(manifestPath))[CODEX_TOOL_ID] ?? []; + const builtins = builtinHookDefs(CODEX_TOOL_ID); + return Object.entries(json.hooks).flatMap(([event, groups]) => (groups ?? []).flatMap((group, index) => { + const command = group.hooks?.[0]?.command; + const builtin = group.hooks?.length === 1 && builtins.some((def) => + def.event === event && def.command === command && toCodexEntry(def).matcher === group.matcher); + if (typeof command !== 'string' || !(builtin || records.some((r) => ownsCodexEntry(r, event, index, groups)))) return []; + const snake = event.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase(); + return [{ file, command, key: `${canonicalProjectRoot(file)}:${snake}:${index}:0` }]; + })); } /** From 79b01c80ed754575add875559958f10f3c50c25f Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 08:55:51 +0200 Subject: [PATCH 09/13] fix(hooks): honor bare workspaces and configured project paths (#955) --- docs/designs/data-directory-layout.md | 4 +- docs/usage-guide.md | 4 +- docs/usage-guide.zh-CN.md | 4 +- skill-data/core/references/troubleshooting.md | 11 ++- src/__tests__/anchors.test.ts | 1 + src/__tests__/codex-trust.test.ts | 88 +++++++++++++++++++ src/__tests__/hooks-reconcile-scope.test.ts | 26 ++++++ src/__tests__/uninstall.test.ts | 32 +++++++ src/hooks-cmd.ts | 2 +- src/hooks.ts | 74 ++++++++++------ src/uninstall.ts | 21 +++-- src/utils/git.ts | 6 +- 12 files changed, 228 insertions(+), 45 deletions(-) diff --git a/docs/designs/data-directory-layout.md b/docs/designs/data-directory-layout.md index c558d4194..0e30bed26 100644 --- a/docs/designs/data-directory-layout.md +++ b/docs/designs/data-directory-layout.md @@ -497,8 +497,10 @@ every checkout, so that is where they live now: ├── pending-learnings/ pendingLearningsDir → /pending-learnings ├── managed-main-checkout-hooks.json team hooks teamai wrote ungated into the main checkout's Claude Code / Codex │ settings, shared by every checkout (#955; the built-in hooks stay in HOME) -│ Codex records event, matcher-group position and complete rendered entry for exact ownership/trust +│ project toolPaths choose the files; Claude uses settings.local.json beside its settings file +│ Codex records event, matcher-group position and complete rendered entry; unique definitions recover moved entries └── workspaces// + ├── managed-main-checkout-hooks.json bare repositories only: this workspace owns its Claude / Codex team-hook files and trust target ├── managed-mcp.json managedMcpManifestPath, one per checkout; Copilot placement is true for bare, false for keyed, absent when unproven ├── managed-mcp-files.json resolvedMcpFilesPath: project MCP configs teamai may have written a resolved ${VAR} to, and whether │ the paths earlier teamai.yaml revisions mapped were read; one of those git tracks is marked tracked (#882); diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 5e37601cc..89da32ce0 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -660,7 +660,7 @@ A manual `teamai pull` ends by running the `teamai doctor` checks and printing e **Pull keeps a skill, rule or agent you changed.** For each checkout, pull records what it wrote at each skill, rule and agent path. On a full sync, a copy that no longer matches that record is kept, and pull names it, while the copies of other tools still update. A skill counts as one copy: a change to any of its team files keeps the whole skill, and files only you added do not count. If the team version has not changed, pull prints ``Kept : you changed it since teamai delivered it. Share it with `teamai push`, or delete it and run `teamai pull --force` to get the team version back.`` If it has, whether the team changed it or your [local model alias override](#local-override) did, pull warns and asks you to merge that change into your copy before you push it, and `teamai push` warns about that copy too, since the SessionStart pull runs silently. `--force` keeps these copies too, and `--dry-run` prints `Would keep ` for each. When the team removes an item, a copy you changed stays, and pull names it. There is no record before your first full pull with this version, so that pull overwrites as earlier versions did, and your changes are protected from then on. The same goes for a new worktree's first pull, and for a copy teamai never delivered to that path. `teamai remove` and installs from the local agent still rewrite the team rules without this check. An older CLI that saves state drops the record. -> Project scope is isolated by default. When the current working directory contains a project-scope `.teamai/config.yaml`, `pull` processes that project and skips user scope unless the local config has `inheritUserScope: true`; in that case it first refreshes the safe user-resource channel. Without a project config in the current directory, `pull` processes user scope. User `env`, MCP definitions, sources, reporting, and writes remain isolated in project mode. Hooks are the one exception: a project scope's built-in hooks are injected into your **HOME** tool settings (`~/.claude/settings.json`, …), not ``, because they gate on the `cwd` handed to `hook-dispatch` and `~/.claude` always exists so the "installed tool" gate passes (see the Hooks section). The team's own hooks (`hooks/hooks.yaml`) for Claude Code and Codex go to the main checkout instead, ungated (`
/.claude/settings.local.json`, `
/.codex/hooks.json`), so every worktree of the project shares one copy; other tools keep them in HOME, run only when the `cwd` is inside the project. In a directory with no teamai config (no project config and no user scope), the team hooks do nothing: no reminders, and no session or skill usage is recorded; only machine-level work runs (the CLI update check, the session-start pull, the local agent, and package hints a pull stashed). For the team hooks and skill usage, a project config that exists but cannot be read counts as none, never as the user scope or as a lower-priority project config (such as a legacy `.teamai/config.yaml`) behind it. `pull` follows the same rule: it syncs no scope there, prints ``Nothing was synced: : . Fix the file, or move it aside and run `teamai init` to write a new one.`` and exits 1 (with `--silent`, it prints nothing and still exits 1); a session start there runs no pull, seeds no agent directory and stashes no package hint. A hook whose `cwd` was deleted (a session that outlives its worktree) keeps the scope its session last recorded, so the session's last events and skill uses stay with the project, and its share reminder follows the project's settings, instead of the user scope's. This needs the session's earlier events in the local event log, which compaction trims to active sessions, and does not cover Copilot, whose events record no directory. Self single-repo mode keeps its hooks in the business repo so they travel on clone. +> Project scope is isolated by default. When the current working directory contains a project-scope `.teamai/config.yaml`, `pull` processes that project and skips user scope unless the local config has `inheritUserScope: true`; in that case it first refreshes the safe user-resource channel. Without a project config in the current directory, `pull` processes user scope. User `env`, MCP definitions, sources, reporting, and writes remain isolated in project mode. Hooks are the one exception: a project scope's built-in hooks are injected into your **HOME** tool settings (`~/.claude/settings.json`, …), not ``, because they gate on the `cwd` handed to `hook-dispatch` and `~/.claude` always exists so the "installed tool" gate passes (see the Hooks section). The team's own hooks (`hooks/hooks.yaml`) for Claude Code and Codex go to the main checkout instead, ungated (`
/.claude/settings.local.json`, `
/.codex/hooks.json`), so every worktree of the project shares one copy. These paths follow the project `toolPaths`; Claude uses `settings.local.json` beside its configured settings file. For a bare repository, each worktree keeps its own copy because there is no main checkout; other tools keep them in HOME, run only when the `cwd` is inside the project. In a directory with no teamai config (no project config and no user scope), the team hooks do nothing: no reminders, and no session or skill usage is recorded; only machine-level work runs (the CLI update check, the session-start pull, the local agent, and package hints a pull stashed). For the team hooks and skill usage, a project config that exists but cannot be read counts as none, never as the user scope or as a lower-priority project config (such as a legacy `.teamai/config.yaml`) behind it. `pull` follows the same rule: it syncs no scope there, prints ``Nothing was synced: : . Fix the file, or move it aside and run `teamai init` to write a new one.`` and exits 1 (with `--silent`, it prints nothing and still exits 1); a session start there runs no pull, seeds no agent directory and stashes no package hint. A hook whose `cwd` was deleted (a session that outlives its worktree) keeps the scope its session last recorded, so the session's last events and skill uses stay with the project, and its share reminder follows the project's settings, instead of the user scope's. This needs the session's earlier events in the local event log, which compaction trims to active sessions, and does not cover Copilot, whose events record no directory. Self single-repo mode keeps its hooks in the business repo so they travel on clone. With role-based skills enabled, `pull`'s skill sync source becomes the contents of `skills//`, expanded according to `primaryRole + additionalRoles` and flattened into each local AI tool's skills directory. `rules//` and `claudemd//` follow the `knowledge` namespaces, and a `docs//` follows the `docs` namespaces once one is declared (see [Docs](#docs)); `agents//` follows the role's `agents` namespaces (see [Agents Resource Type](#agents-resource-type)). `learnings/` at the root is shared with everyone, while `learnings//` subdirectories sync only for the directory's active projects (see [Multi-project](#multi-project-project-as-a-dimension-orthogonal-to-role)). @@ -1926,7 +1926,7 @@ On Windows, the built-in hook dispatch commands that shell out through bash (e.g Cursor also loads `~/.claude/settings.json`, and Copilot CLI loads a trusted project's `.claude/settings.json` (self mode writes hooks there; Copilot does not load `~/.claude/settings.json`). `hook-dispatch --tool claude` exits only when that other host's own teamai hooks are on disk: `~/.cursor/hooks.json` or `$CURSOR_PROJECT_DIR/.cursor/hooks.json` contains `--tool cursor`, or `$COPILOT_PROJECT_DIR/.github/hooks/teamai.json` contains `--tool copilot`. Team hook commands written for `claude` use the same check. A setup with only Claude keeps running inside Cursor, because there is no second copy. `COPILOT_CLI` is not a signal: Copilot sets it on every subprocess, including a Claude session started from its shell. Claude Code sets neither `CURSOR_VERSION` nor `COPILOT_PROJECT_DIR`. Run `teamai pull` or `teamai hooks inject` again so an already installed team hook picks up the guard. -> **Codex hook trust** — Codex (the OpenAI / ChatGPT Codex app, tool id `codex`) runs a non-managed hook only once it is trusted, and skips an untrusted or changed one without a word; it reads a project's `.codex/` only when the project is trusted. So after every write of a Codex hooks file (`init`, every `pull` including the session-start one, `teamai hooks inject`) teamai trusts exactly the hooks it wrote, through `codex app-server` — the same call Codex's `/hooks` trust prompt makes. Your own hooks in the same file are left alone, even when their commands equal a team hook. Codex ownership records include the event, position and complete generated entry; trust selects that exact Codex key. Unrecorded or ambiguous legacy team-hook copies are preserved. In a project, teamai also trusts the main checkout when Codex has to read teamai's hooks or MCP servers from its `.codex/`; a project you marked untrusted in Codex stays so, and teamai says so. Trust written by a session-start pull applies from the next Codex session: the running one already loaded its hooks. A linked worktree reads the main checkout's `.codex/hooks.json` only once it has a `.codex/` directory, which the Codex session-start hook creates, so a new worktree gets the team hooks from its second Codex session; the built-in hooks live in `~/.codex/hooks.json` and run from the first. To trust them yourself, set `codexTrustEnabled: false` in `config.yaml`. `init` and `hooks inject` print a reminder to trust them in `/hooks` or Settings → Hooks when `codex` is absent or its app-server fails. An interactive pull warns on app-server failure and stays quiet when `codex` is absent; silent pulls record the result in the debug log. `teamai doctor` asks Codex which teamai hooks it will not run and names them. +> **Codex hook trust** — Codex (the OpenAI / ChatGPT Codex app, tool id `codex`) runs a non-managed hook only once it is trusted, and skips an untrusted or changed one without a word; it reads a project's `.codex/` only when the project is trusted. So after every write of a Codex hooks file (`init`, every `pull` including the session-start one, `teamai hooks inject`) teamai trusts exactly the hooks it wrote, through `codex app-server` — the same call Codex's `/hooks` trust prompt makes. Your own hooks in the same file are left alone, even when their commands equal a team hook. Codex ownership records include the event, position and complete generated entry; trust selects that exact Codex key. If unrelated entries move it, teamai recovers ownership only when the complete definition matches uniquely. Unrecorded or ambiguous legacy team-hook copies are preserved. In a project, teamai also trusts the main checkout when Codex has to read teamai's hooks or MCP servers from its `.codex/`; for a bare repository, teamai writes and trusts the current worktree instead. A project you marked untrusted in Codex stays so, and teamai says so. Trust written by a session-start pull applies from the next Codex session: the running one already loaded its hooks. A linked worktree reads the main checkout's `.codex/hooks.json` only once it has a `.codex/` directory, which the Codex session-start hook creates, so a new worktree gets the team hooks from its second Codex session; the built-in hooks live in `~/.codex/hooks.json` and run from the first. To trust them yourself, set `codexTrustEnabled: false` in `config.yaml`. `init` and `hooks inject` print a reminder to trust them in `/hooks` or Settings → Hooks when `codex` is absent or its app-server fails. An interactive pull warns on app-server failure and stays quiet when `codex` is absent; silent pulls record the result in the debug log. `teamai doctor` asks Codex which teamai hooks it will not run and names them. ### Team Hooks Declaration diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 4a6517283..f3c9c5f76 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -586,7 +586,7 @@ teamai pull --dry-run # 试运行,不实际修改 **pull 会保留你修改过的 skill、rule 和 agent。** pull 按检出记录它在每个 skill、rule、agent 路径写入的内容。完整同步时,与记录不一致的副本会被保留并由 pull 指出,其他工具的副本照常更新。一个 skill 算作一份副本:它的任一团队文件被改动,整个 skill 都会保留;只有你自己添加的文件不计入。团队版本没有变化时,pull 输出 ``Kept : you changed it since teamai delivered it. Share it with `teamai push`, or delete it and run `teamai pull --force` to get the team version back.``;团队版本也变了时(无论是团队改的,还是你的[本地模型别名覆盖](#本地覆盖)导致的),pull 给出警告,请你先把这项改动合并进自己的副本,再 push;由于 SessionStart 时的 pull 不输出信息,`teamai push` 也会对该副本给出警告。`--force` 同样保留这些副本,`--dry-run` 会逐个输出 `Would keep `。团队删除某项资源时,你修改过的副本也会保留,并由 pull 指出。升级后第一次完整 pull 之前还没有记录,因此那次 pull 仍像旧版本一样覆盖,此后你的修改才受保护。新 worktree 的第一次 pull、以及 teamai 从未写入过该路径的副本,同样如此。`teamai remove` 和本地 agent 的安装仍会不经这项检查重写团队 rule。旧版 CLI 保存 state 时会丢弃这份记录。 -> Project scope 默认与 user scope 隔离。当前工作目录包含 project scope 的 `.teamai/config.yaml` 时,`pull` 会处理该项目并跳过 user scope;仅当本地配置包含 `inheritUserScope: true` 时,才会先刷新安全的 user 资源通道。当前目录没有 project 配置时,`pull` 处理 user scope。project 模式下,user 的 `env`、MCP 定义、sources、reporting 和写入行为仍保持隔离。hooks 是唯一例外:project scope 的内置 hooks 会注入到你的 **HOME** 工具设置(`~/.claude/settings.json` 等),而非 ``——因为它们依据传给 `hook-dispatch` 的 `cwd` 门控,且 `~/.claude` 恒存在、能通过「已安装工具」门槛(详见 Hooks 章节)。团队自己的 hooks(`hooks/hooks.yaml`)对 Claude Code 和 Codex 则写入主 checkout,不加门控(`<主 checkout>/.claude/settings.local.json`、`<主 checkout>/.codex/hooks.json`),项目的所有 worktree 共用一份;其他工具仍写在 HOME,仅在 `cwd` 位于该项目内时运行。在没有 teamai 配置的目录中(既没有 project 配置也没有 user scope),团队 hooks 不做任何事:不显示提醒,也不记录会话或 skill 使用;只运行机器级别的工作(CLI 更新检查、SessionStart 时的 pull、本地 agent,以及 pull 暂存的包提示)。对团队 hooks 和 skill 使用记录而言,存在但无法读取的 project 配置视为没有配置,而不会退回 user scope,也不会退回其后优先级更低的 project 配置(如旧的 `.teamai/config.yaml`)。`pull` 遵循同一规则:此时不同步任何 scope,输出 ``Nothing was synced: : . Fix the file, or move it aside and run `teamai init` to write a new one.`` 并以 exit 1 退出(加 `--silent` 时不输出,但仍以 exit 1 退出);会话启动时不运行 pull,也不创建 agent 目录、不暂存包提示。`cwd` 已被删除的 hook(会话比它的 worktree 活得更久)沿用该会话最后记录的 scope,因此会话最后的事件和 skill 使用仍归属项目,分享提醒也遵循项目的设置,而不是 user scope 的。这需要本地事件日志中仍保留该会话之前的事件(压缩只保留活跃会话),且不适用于 Copilot,因为它的事件不记录目录。self 单仓模式则把 hooks 保留在业务仓库里,随 clone 传播。 +> Project scope 默认与 user scope 隔离。当前工作目录包含 project scope 的 `.teamai/config.yaml` 时,`pull` 会处理该项目并跳过 user scope;仅当本地配置包含 `inheritUserScope: true` 时,才会先刷新安全的 user 资源通道。当前目录没有 project 配置时,`pull` 处理 user scope。project 模式下,user 的 `env`、MCP 定义、sources、reporting 和写入行为仍保持隔离。hooks 是唯一例外:project scope 的内置 hooks 会注入到你的 **HOME** 工具设置(`~/.claude/settings.json` 等),而非 ``——因为它们依据传给 `hook-dispatch` 的 `cwd` 门控,且 `~/.claude` 恒存在、能通过「已安装工具」门槛(详见 Hooks 章节)。团队自己的 hooks(`hooks/hooks.yaml`)对 Claude Code 和 Codex 则写入主 checkout,不加门控(`<主 checkout>/.claude/settings.local.json`、`<主 checkout>/.codex/hooks.json`),项目的所有 worktree 共用一份。路径遵循项目的 `toolPaths`;Claude 在其配置的 settings 文件旁使用 `settings.local.json`。bare 仓库没有主 checkout,因此各 worktree 保留自己的副本;其他工具仍写在 HOME,仅在 `cwd` 位于该项目内时运行。在没有 teamai 配置的目录中(既没有 project 配置也没有 user scope),团队 hooks 不做任何事:不显示提醒,也不记录会话或 skill 使用;只运行机器级别的工作(CLI 更新检查、SessionStart 时的 pull、本地 agent,以及 pull 暂存的包提示)。对团队 hooks 和 skill 使用记录而言,存在但无法读取的 project 配置视为没有配置,而不会退回 user scope,也不会退回其后优先级更低的 project 配置(如旧的 `.teamai/config.yaml`)。`pull` 遵循同一规则:此时不同步任何 scope,输出 ``Nothing was synced: : . Fix the file, or move it aside and run `teamai init` to write a new one.`` 并以 exit 1 退出(加 `--silent` 时不输出,但仍以 exit 1 退出);会话启动时不运行 pull,也不创建 agent 目录、不暂存包提示。`cwd` 已被删除的 hook(会话比它的 worktree 活得更久)沿用该会话最后记录的 scope,因此会话最后的事件和 skill 使用仍归属项目,分享提醒也遵循项目的设置,而不是 user scope 的。这需要本地事件日志中仍保留该会话之前的事件(压缩只保留活跃会话),且不适用于 Copilot,因为它的事件不记录目录。self 单仓模式则把 hooks 保留在业务仓库里,随 clone 传播。 启用角色化 skills 后,`pull` 的 skills 同步来源会变成 `skills//` 中的内容,按 `primaryRole + additionalRoles` 展开对应的 namespace,拍平安装到本地各 AI 工具 skills 目录。`rules//` 和 `claudemd//` 按 `knowledge` namespace 同步,`docs//` 在被声明后按 `docs` namespace 同步(见 [Docs(文档)](#docs文档));`agents//` 按角色的 `agents` namespace 同步(见 [Agents 资源类型](#agents-资源类型))。`learnings/` 根目录对所有人共享,而 `learnings//` 子目录只对本目录激活的项目同步(见 [多项目](#多项目project-作为与-role-正交的维度))。 @@ -1791,7 +1791,7 @@ inject 和 remove 只会操作你实际已安装的工具(即 `~/./` 根 Cursor 也会加载 `~/.claude/settings.json`。Copilot CLI 会加载受信任项目里的 `.claude/settings.json`(self mode 把 hook 写在项目里;Copilot 不加载 `~/.claude/settings.json`)。只有另一边的 teamai hook 已经在磁盘上时,`hook-dispatch --tool claude` 才会退出:`~/.cursor/hooks.json` 或 `$CURSOR_PROJECT_DIR/.cursor/hooks.json` 含有 `--tool cursor`,或 `$COPILOT_PROJECT_DIR/.github/hooks/teamai.json` 含有 `--tool copilot`。写给 `claude` 的团队 hook 命令用同一判断。只启用了 Claude 时,Cursor 里这份 hook 照常运行,因为没有第二份可以接替。`COPILOT_CLI` 不能当信号:Copilot 会给每个子进程设置它,包括从它的 shell 里启动的 Claude。Claude Code 不会设置 `CURSOR_VERSION` 或 `COPILOT_PROJECT_DIR`。已经装好的团队 hook 需要再跑一次 `teamai pull` 或 `teamai hooks inject`,才会带上这个判断。 -> **Codex hook 信任** — Codex(OpenAI / ChatGPT Codex 应用,工具 id 为 `codex`)只运行已信任的非托管 hook,未信任或已变更的 hook 会被静默跳过;且只有项目被信任时才读取其 `.codex/`。因此每次写入 Codex hooks 文件后(`init`、每次 `pull`(含 SessionStart 触发的 pull)、`teamai hooks inject`),teamai 都会通过 `codex app-server` 信任它写入的那些 hook——与 Codex `/hooks` 信任提示调用的是同一接口。同一文件里你自己的 hook 不受影响,即使命令与团队 hook 相同。Codex 所有权记录包含事件、位置和完整生成条目,信任操作只选择对应的 Codex key。没有所有权记录或无法区分的旧团队 hook 副本会保留。在项目中,当 Codex 需要从主 checkout 的 `.codex/` 读取 teamai 的 hooks 或 MCP servers 时,teamai 也会信任该主 checkout;你在 Codex 中标记为不信任的项目保持不变,teamai 会提示。SessionStart 触发的 pull 写入的信任从下一个 Codex 会话起生效:当前会话已加载了它的 hooks。linked worktree 只有在存在 `.codex/` 目录时才读取主 checkout 的 `.codex/hooks.json`,该目录由 Codex 的 SessionStart hook 创建,因此新 worktree 从第二个 Codex 会话起获得团队 hooks;内置 hooks 位于 `~/.codex/hooks.json`,从第一个会话起就运行。若要自行信任,在 `config.yaml` 中设置 `codexTrustEnabled: false`。PATH 中没有 `codex` 或 app-server 失败时,`init` 和 `hooks inject` 会提示你在 `/hooks` 或 Settings → Hooks 中信任。交互式 pull 仅在 app-server 失败时警告,缺少 `codex` 时保持静默;silent pull 将结果记录在 debug 日志中。`teamai doctor` 会向 Codex 查询哪些 teamai hooks 不会运行并逐一列出。 +> **Codex hook 信任** — Codex(OpenAI / ChatGPT Codex 应用,工具 id 为 `codex`)只运行已信任的非托管 hook,未信任或已变更的 hook 会被静默跳过;且只有项目被信任时才读取其 `.codex/`。因此每次写入 Codex hooks 文件后(`init`、每次 `pull`(含 SessionStart 触发的 pull)、`teamai hooks inject`),teamai 都会通过 `codex app-server` 信任它写入的那些 hook——与 Codex `/hooks` 信任提示调用的是同一接口。同一文件里你自己的 hook 不受影响,即使命令与团队 hook 相同。Codex 所有权记录包含事件、位置和完整生成条目,信任操作只选择对应的 Codex key。其他条目移动它的位置时,仅在完整定义唯一匹配时恢复所有权。没有所有权记录或无法区分的旧团队 hook 副本会保留。在项目中,当 Codex 需要从主 checkout 的 `.codex/` 读取 teamai 的 hooks 或 MCP servers 时,teamai 也会信任该主 checkout;bare 仓库则在当前 worktree 写入并信任。你在 Codex 中标记为不信任的项目保持不变,teamai 会提示。SessionStart 触发的 pull 写入的信任从下一个 Codex 会话起生效:当前会话已加载了它的 hooks。linked worktree 只有在存在 `.codex/` 目录时才读取主 checkout 的 `.codex/hooks.json`,该目录由 Codex 的 SessionStart hook 创建,因此新 worktree 从第二个 Codex 会话起获得团队 hooks;内置 hooks 位于 `~/.codex/hooks.json`,从第一个会话起就运行。若要自行信任,在 `config.yaml` 中设置 `codexTrustEnabled: false`。PATH 中没有 `codex` 或 app-server 失败时,`init` 和 `hooks inject` 会提示你在 `/hooks` 或 Settings → Hooks 中信任。交互式 pull 仅在 app-server 失败时警告,缺少 `codex` 时保持静默;silent pull 将结果记录在 debug 日志中。`teamai doctor` 会向 Codex 查询哪些 teamai hooks 不会运行并逐一列出。 ### 团队 Hooks 声明 diff --git a/skill-data/core/references/troubleshooting.md b/skill-data/core/references/troubleshooting.md index d4fdefacf..2c2aed808 100644 --- a/skill-data/core/references/troubleshooting.md +++ b/skill-data/core/references/troubleshooting.md @@ -167,15 +167,18 @@ step — do not assume auto-sync just works. Codex runs a non-managed hook only once it is **trusted**. `teamai init`, `pull` and `teamai hooks inject` trust the hooks they write (and, in a project, the main -checkout) through `codex app-server`; trust written by a session-start pull applies -from the next Codex session. `teamai doctor` names any teamai hook Codex will not +checkout, or the current worktree for a bare repository) through `codex app-server`. +Trust written by a session-start pull applies from the next Codex session. `teamai doctor` names any teamai hook Codex will not run. Then: run `teamai pull`; if `codex` is not on PATH or `codexTrustEnabled: false` is set in `config.yaml`, guide the user to trust the teamai hooks in Codex `/hooks`, then reopen a session. A new linked worktree gets the team hooks from its second Codex session (the first creates its `.codex/`). Member hooks with the same command are preserved and remain untouched by automatic trust. Codex ownership uses the -recorded event, position and complete entry; unrecorded or ambiguous legacy team-hook copies -are preserved rather than claimed by command. +recorded event, position and complete entry. A moved entry is recovered only by a +unique full-definition match; unrecorded or ambiguous legacy team-hook copies +are preserved rather than claimed by command. Project hook paths follow `toolPaths`; +Claude uses `settings.local.json` beside its configured settings file. A custom +Codex path that Codex does not load is reported as `not loaded` by doctor. ### Cursor diff --git a/src/__tests__/anchors.test.ts b/src/__tests__/anchors.test.ts index 621b114d5..133fb3e23 100644 --- a/src/__tests__/anchors.test.ts +++ b/src/__tests__/anchors.test.ts @@ -201,6 +201,7 @@ describe('defaultProjectSlug (#809)', () => { const checkout = path.join(base, 'bare-layout', wt); git(bare, 'worktree', 'add', '-q', checkout); expect((await resolveAnchors(checkout))?.projectAnchor).toBe(bare); + expect((await resolveAnchors(checkout))?.projectAnchorIsBare).toBe(true); expect(await defaultProjectSlug(checkout)).toBe('bare-layout'); } const sub = path.join(base, 'bare-layout', 'main', 'pkg'); diff --git a/src/__tests__/codex-trust.test.ts b/src/__tests__/codex-trust.test.ts index 51f078f0f..097258c5d 100644 --- a/src/__tests__/codex-trust.test.ts +++ b/src/__tests__/codex-trust.test.ts @@ -103,6 +103,29 @@ afterEach(async () => { }); describe('Codex hook ownership', () => { + it('recovers a unique managed definition after member groups move it', async () => { + await writeYaml(LINT_HOOK); + await writeAndTrust(userConfig()); + const file = path.join(codexHome(), 'hooks.json'); + const json = await fse.readJson(file); + const member = { hooks: [{ type: 'command', command: 'echo member' }] }; + json.hooks.PreToolUse.unshift(member); + await fse.writeJson(file, json); + + await writeAndTrust(userConfig()); + + expect((await fse.readJson(file)).hooks.PreToolUse).toEqual([ + member, { hooks: [{ type: 'command', command: 'npm run lint' }] }, + ]); + await writeYaml(LINT_HOOK.replace('npm run lint', 'npm run lint:fix')); + await writeAndTrust(userConfig()); + expect((await fse.readJson(file)).hooks.PreToolUse).toEqual([ + member, { hooks: [{ type: 'command', command: 'npm run lint:fix' }] }, + ]); + await writeAndTrust(userConfig(), { removeAll: true }); + expect((await fse.readJson(file)).hooks.PreToolUse).toEqual([member]); + }); + it('preserves member hooks on the first project reconcile and through team updates/removal', async () => { await writeYaml(LINT_HOOK); const project = await fse.realpath(await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-codex-owner-project-'))); @@ -441,6 +464,71 @@ describe('Codex trust — project scopes', () => { } as Partial); } + it('writes and trusts team hooks in the current workspace when the project anchor is bare', async () => { + const bare = path.join(home, 'bare.git'); + await fse.ensureDir(bare); + vi.mocked(resolveAnchors).mockResolvedValue({ + workspaceRoot: project, projectAnchor: bare, projectAnchorIsBare: true, + }); + await writeYaml(LINT_HOOK); + + const result = await writeAndTrust(projectConfig()); + + const file = path.join(project, '.codex', 'hooks.json'); + expect(await fse.pathExists(file)).toBe(true); + expect((await fse.readJson(file)).hooks.PreToolUse[0].hooks[0].command).toBe('npm run lint'); + expect(await fse.pathExists(path.join(bare, '.codex'))).toBe(false); + expect(result.codexTrust).toMatchObject({ kind: 'trusted', project }); + expect(readFakeCodexState(codexHome()).projects).toEqual({ [project]: { trust_level: 'trusted' } }); + expect(await readCodexHookTrustForScope(teamConfig, projectConfig())).toEqual({ kind: 'listed', notTrusted: [] }); + }); + + it('uses the configured project file in trust and doctor when Codex does not load it', async () => { + await writeYaml(LINT_HOOK); + const configured = { ...teamConfig, toolPaths: { + codex: { settings: '.custom-codex/hooks.json', userScope: { settings: '.codex/hooks.json' } }, + } } as TeamaiConfig; + const cfg = projectConfig(); + await reconcileTeamHooksForConfig(configured, cfg); + const file = path.join(project, '.custom-codex', 'hooks.json'); + + expect(await fse.pathExists(file)).toBe(true); + expect(await fse.pathExists(path.join(project, '.codex', 'hooks.json'))).toBe(false); + expect(await trustCodexForScope(configured, cfg)).toEqual({ + kind: 'failed', reason: expect.stringContaining(file), + }); + expect(await readCodexHookTrustForScope(configured, cfg)).toEqual({ + kind: 'listed', notTrusted: [ + ...(await codexEntries(path.join(codexHome(), 'hooks.json'))).map(({ command }) => ({ + file: path.join(codexHome(), 'hooks.json'), command, status: 'untrusted', + })), + { file, command: 'npm run lint', status: 'not loaded' }, + ], + }); + }); + + it('keeps separate hook ownership for bare worktrees sharing one data home', async () => { + const other = path.join(home, 'other-worktree'); + const bare = path.join(home, 'bare.git'); + await fse.ensureDir(other); + await fse.ensureDir(bare); + vi.mocked(resolveAnchors).mockImplementation(async (cwd) => ({ + workspaceRoot: cwd!, projectAnchor: bare, projectAnchorIsBare: true, + })); + const dataHome = path.join(home, 'shared-data'); + const first = projectConfig({ dataHome }); + const second = projectConfig({ dataHome, projectRoot: other }); + await writeYaml(LINT_HOOK); + await writeAndTrust(first); + await writeYaml(LINT_HOOK.replace('npm run lint', 'npm run lint:fix')); + await writeAndTrust(second); + + await writeAndTrust(first, { removeAll: true }); + + expect((await fse.readJson(path.join(project, '.codex', 'hooks.json'))).hooks.PreToolUse).toEqual([]); + expect((await fse.readJson(path.join(other, '.codex', 'hooks.json'))).hooks.PreToolUse[0].hooks[0].command).toBe('npm run lint:fix'); + }); + it('trustCodexProject resolves symlinks and preserves an explicit untrusted choice', async () => { const alias = path.join(home, 'project-link'); await fse.ensureSymlink(project, alias, 'dir'); diff --git a/src/__tests__/hooks-reconcile-scope.test.ts b/src/__tests__/hooks-reconcile-scope.test.ts index 868aea072..42f0b3409 100644 --- a/src/__tests__/hooks-reconcile-scope.test.ts +++ b/src/__tests__/hooks-reconcile-scope.test.ts @@ -482,6 +482,32 @@ builtin: describe('reconcileTeamHooksForConfig — team hooks in the main checkout', () => { const STOP_LINT = 'hooks:\n - id: lint\n description: lint\n event: Stop\n command: npm run lint\n'; + it('uses project toolPaths for main hooks and userScope paths for HOME built-ins', async () => { + await writeYaml(STOP_LINT); + const custom = { toolPaths: { + claude: { settings: '.custom-claude/settings.json', userScope: { settings: '.claude/settings.json' } }, + codex: { settings: '.custom-codex/hooks.json', userScope: { settings: '.codex/hooks.json' } }, + } } as unknown as TeamaiConfig; + const files = [path.join(project, '.custom-claude', 'settings.local.json'), path.join(project, '.custom-codex', 'hooks.json')]; + + await reconcileTeamHooksForConfig(custom, localConfig()); + + for (const file of files) { + expect(await fse.pathExists(file)).toBe(true); + expect((await fse.readJson(file)).hooks.Stop[0].hooks[0].command).toContain('npm run lint'); + expect(await fse.readFile(file, 'utf8')).not.toContain('$PWD'); + } + expect((await claudeSettings()).hooks.SessionStart).toHaveLength(1); + expect((await codexSettings()).hooks.SessionStart).toHaveLength(1); + expect(await fse.pathExists(path.join(project, '.claude', 'settings.local.json'))).toBe(false); + expect(await fse.pathExists(path.join(project, '.codex', 'hooks.json'))).toBe(false); + const before = await Promise.all(files.map((file) => fse.readFile(file, 'utf8'))); + await reconcileTeamHooksForConfig(custom, localConfig()); + expect(await Promise.all(files.map((file) => fse.readFile(file, 'utf8')))).toEqual(before); + await reconcileTeamHooksForConfig(custom, localConfig(), { removeAll: true }); + for (const file of files) expect((await fse.readJson(file)).hooks.Stop).toEqual([]); + }); + it('keeps one HOME-gated internal Codex hook per project when their pulls alternate', async () => { await writeYaml(STOP_LINT); const other = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-internal-other-')); diff --git a/src/__tests__/uninstall.test.ts b/src/__tests__/uninstall.test.ts index 451b57fd9..e2e5d8f9e 100644 --- a/src/__tests__/uninstall.test.ts +++ b/src/__tests__/uninstall.test.ts @@ -2021,6 +2021,38 @@ describe('uninstall', () => { ); }); + it('removes the separate team-hook files of live bare worktrees (#955)', async () => { + const bare = path.join(tmpDir, 'bare.git'); + const first = path.join(tmpDir, 'first'); + const second = path.join(tmpDir, 'second'); + execFileSync('git', ['init', '--bare', bare]); + execFileSync('git', ['--git-dir', bare, 'worktree', 'add', '--orphan', first]); + execFileSync('git', ['--git-dir', bare, 'worktree', 'add', '--orphan', second]); + const repoPath = path.join(tmpDir, 'team-repo'); + const homeDir = path.join(tmpDir, 'home'); + const dataHome = path.join(homeDir, '.teamai', 'shared-project'); + await fse.ensureDir(repoPath); + await fse.ensureDir(dataHome); + for (const root of [first, second]) { + await fse.outputJson(path.join(root, '.claude', 'settings.local.json'), { + hooks: { Stop: [{ matcher: '*', hooks: [{ type: 'command', command: 'npm run lint' }], description: '[teamai:hook:lint] lint' }] }, + }); + } + vi.stubEnv('HOME', homeDir); + const localConfig = makeLocalConfig(homeDir, repoPath, { scope: 'project', projectRoot: first, dataHome }); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig: makeTeamConfig() }); + + await uninstall({ force: true }); + + const calls = mockReconcileHooks.mock.calls.filter((c) => String(c[0]).endsWith('settings.local.json')); + const files = await Promise.all([first, second].map(async (root) => + path.join(await fse.realpath(root), '.claude', 'settings.local.json'))); + expect(calls.map((c) => c[0]).sort()).toEqual(files.sort()); + const manifests = calls.map((c) => c[3].manifestPath); + expect(new Set(manifests).size).toBe(2); + for (const manifest of manifests) expect(manifest).toMatch(/workspaces[/\\][a-f0-9]+[/\\]managed-main-checkout-hooks\.json$/); + }); + // #667: hook discovery must resolve the settings *file* at the scope hooks // were injected into, not at the config's scope. Qoder CN reads // `~/.qoder-cn/` for its user scope, so a non-self project scope (which diff --git a/src/hooks-cmd.ts b/src/hooks-cmd.ts index cd85529af..6de37cf66 100644 --- a/src/hooks-cmd.ts +++ b/src/hooks-cmd.ts @@ -282,7 +282,7 @@ export async function hooksRemove(_options: GlobalOptions): Promise { ? localConfig.projectRoot : undefined, // The project's Claude and Codex team hooks live in the main checkout. - mainCheckout: await resolveMainCheckoutHooks(localConfig), + mainCheckout: await resolveMainCheckoutHooks(localConfig, teamConfig.toolPaths), }); const copilotPaths = scopedToolPaths(teamConfig, localConfig)[COPILOT_TOOL_ID]; diff --git a/src/hooks.ts b/src/hooks.ts index c81b723e8..21d50b72b 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -27,6 +27,7 @@ import { isSelfMode, managedMcpManifestKey, managedMcpManifestPath, + managedMcpWorkspaceId, resolveToolRootDir, } from './types.js'; import { builtinHookDefs, applyBuiltinOverride, skipToolsWithoutShell, toolUsesCmdShell } from './builtin-hooks.js'; @@ -280,7 +281,7 @@ function teamDefsForTool(teamDefs: HookDef[], tool: string): HookDef[] { /** * Build the per-tool desired HookDef set: built-in (A) followed by team (B). * `teamOnly` leaves the built-ins out, for a file that holds team hooks only - * (the main checkout's, see MAIN_CHECKOUT_TEAM_HOOK_FILES). + * (the main checkout's, see MAIN_CHECKOUT_TEAM_HOOK_TOOLS). */ function desiredDefs( tool: string, @@ -446,7 +447,7 @@ const POSIX_GATE_ROOT_RE = /^if \[ "\$PWD" = ('(?:[^']|'"'"')*') \] \|\| case "\ /** * The project root a POSIX gate was rendered for, or null for an ungated or * cmd.exe-gated command. Only the POSIX form is read: it is the one the tools - * that moved to the main checkout (MAIN_CHECKOUT_TEAM_HOOK_FILES) ever wrote. + * that moved to the main checkout (MAIN_CHECKOUT_TEAM_HOOK_TOOLS) ever wrote. */ function gatedProjectRoot(command: string): string | null { const quoted = POSIX_GATE_ROOT_RE.exec(command)?.[1]; @@ -465,17 +466,18 @@ function gatedProjectRoot(command: string): string | null { * HOME, which every worktree reads from its first session. Every other tool * keeps its team hooks in HOME behind a `$PWD` gate. */ -const MAIN_CHECKOUT_TEAM_HOOK_FILES: Readonly> = { - claude: '.claude/settings.local.json', - codex: '.codex/hooks.json', -}; +const MAIN_CHECKOUT_TEAM_HOOK_TOOLS = ['claude', 'codex'] as const; -/** Where a non-self project scope keeps the team hooks of MAIN_CHECKOUT_TEAM_HOOK_FILES' tools. */ +/** Where a non-self project scope keeps Claude/Codex team hooks. */ export interface MainCheckoutHooks { - /** The main checkout (realpath), shared by every linked worktree. */ + /** The main checkout (realpath), or the current workspace when the anchor is bare. */ root: string; - /** Manifest of the team hooks written there, in the project's shared data home. */ + /** Bare repositories have no main checkout; each workspace keeps its own files. */ + worktreeScoped: boolean; + /** Manifest of the team hooks written in this target root. */ manifestPath: string; + /** Configured project-scope targets; Claude uses settings.local.json beside its settings file. */ + files: Readonly>; } /** @@ -484,27 +486,43 @@ export interface MainCheckoutHooks { * (which writes every hook into its own checkout), or a project rooted at HOME, * whose "main checkout" files are the HOME files themselves. */ -export async function resolveMainCheckoutHooks(localConfig: LocalConfig): Promise { +export async function resolveMainCheckoutHooks( + localConfig: LocalConfig, + toolPaths: Record, +): Promise { if (localConfig.scope !== 'project' || !localConfig.projectRoot || isSelfMode(localConfig)) return null; - const root = await mainCheckoutOf(localConfig.projectRoot); + const { root, worktreeScoped } = await mainCheckoutOf(localConfig.projectRoot); if (root === canonicalProjectRoot(getUserHome())) return null; - return { root, manifestPath: path.join(getDataHome(localConfig), 'managed-main-checkout-hooks.json') }; + const files = Object.fromEntries(MAIN_CHECKOUT_TEAM_HOOK_TOOLS.flatMap((tool) => { + const settings = toolPaths[tool]?.settings; + if (!settings) return []; + const relative = tool === 'claude' ? path.join(path.dirname(settings), 'settings.local.json') : settings; + return [[tool, path.join(root, relative)]]; + })); + const manifestRoot = worktreeScoped + ? path.join(getDataHome(localConfig), 'workspaces', managedMcpWorkspaceId(root)) + : getDataHome(localConfig); + return { root, worktreeScoped, manifestPath: path.join(manifestRoot, 'managed-main-checkout-hooks.json'), files }; } /** * The main checkout (realpath) of the repository `projectRoot` belongs to, or * `projectRoot` itself outside git — including a checkout that no longer - * exists, which git cannot be asked about. + * exists, which git cannot be asked about. A bare anchor has no main checkout, + * so each actual workspace supplies its own hook files and ownership. */ -async function mainCheckoutOf(projectRoot: string): Promise { +async function mainCheckoutOf(projectRoot: string): Promise<{ root: string; worktreeScoped: boolean }> { const anchors = await pathExists(projectRoot) ? await resolveAnchors(projectRoot) : null; - return anchors?.projectAnchor ?? canonicalProjectRoot(projectRoot); + return { + root: (anchors?.projectAnchorIsBare ? anchors.workspaceRoot : anchors?.projectAnchor) + ?? canonicalProjectRoot(projectRoot), + worktreeScoped: anchors?.projectAnchorIsBare === true, + }; } /** The main checkout's team hook file of `tool`, when the tool keeps one there. */ export function mainCheckoutHookFile(mainCheckout: MainCheckoutHooks | null | undefined, tool: string): string | null { - const relative = MAIN_CHECKOUT_TEAM_HOOK_FILES[tool]; - return mainCheckout && relative ? path.join(mainCheckout.root, relative) : null; + return mainCheckout?.files[tool] ?? null; } /** @@ -1007,18 +1025,16 @@ async function reconcileCodexFormat( return records; } -/** Legacy records identify only an unambiguous, exact default entry under their event. */ +/** Recover a moved entry only when its complete recorded definition is unique. */ function ownsCodexEntry(record: ManagedHookRecord, event: string, index: number, entries: CodexHookMatcher[]): boolean { if (record.event !== event) return false; const entry = entries[index]; - if (record.codexEntry) { - return record.codexEntryIndex === index && isDeepStrictEqual(entry, record.codexEntry); - } - const legacy = { + if (record.codexEntry && record.codexEntryIndex === index && isDeepStrictEqual(entry, record.codexEntry)) return true; + const recorded = record.codexEntry ?? { ...(record.matcher ? { matcher: record.matcher } : {}), hooks: [{ type: 'command', command: record.command }], }; - return isDeepStrictEqual(entry, legacy) && entries.filter((candidate) => isDeepStrictEqual(candidate, legacy)).length === 1; + return isDeepStrictEqual(entry, recorded) && entries.filter((candidate) => isDeepStrictEqual(candidate, recorded)).length === 1; } // ─── ZCode (~/.zcode/cli/config.json) reconcile ───────────── @@ -1942,7 +1958,7 @@ export async function reconcileHooksToAllTools( /** * Reconcile the team hooks of one tool's main-checkout file - * (MAIN_CHECKOUT_TEAM_HOOK_FILES). Nothing is created for a tool without team + * (MAIN_CHECKOUT_TEAM_HOOK_TOOLS). Nothing is created for a tool without team * hooks: a business repo that never had any gets no `.claude/settings.local.json` * or `.codex/` from teamai. */ @@ -2013,8 +2029,8 @@ async function codexTrustTargets( let anchor: string | undefined; let mainFile: string | null = null; if (localConfig.scope === 'project' && localConfig.projectRoot) { - anchor = await mainCheckoutOf(localConfig.projectRoot); - const mainCheckout = await resolveMainCheckoutHooks(localConfig); + anchor = (await mainCheckoutOf(localConfig.projectRoot)).root; + const mainCheckout = await resolveMainCheckoutHooks(localConfig, teamConfig.toolPaths); mainFile = mainCheckoutHookFile(mainCheckout, CODEX_TOOL_ID); if (mainCheckout && mainFile) { sources.push({ file: mainFile, manifestPath: mainCheckout.manifestPath }); @@ -2141,9 +2157,9 @@ export async function sweepLegacyProjectHooks( const legacy = resolveLegacyProjectHookScope(localConfig); if (!legacy) return; // In the main checkout, Codex's legacy file is the one its team hooks now - // live in (MAIN_CHECKOUT_TEAM_HOOK_FILES); that pass removes the legacy + // live in (MAIN_CHECKOUT_TEAM_HOOK_TOOLS); that pass removes the legacy // built-ins from it, so the sweep must not empty it. - const mainCheckout = await resolveMainCheckoutHooks(localConfig); + const mainCheckout = await resolveMainCheckoutHooks(localConfig, toolPaths); const legacyToolPaths = Object.fromEntries(Object.entries(toolPaths).filter(([tool, paths]) => { const mainFile = mainCheckoutHookFile(mainCheckout, tool); return !(mainFile && paths.settings && path.join(canonicalProjectRoot(legacy.baseDir), paths.settings) === mainFile); @@ -2262,7 +2278,7 @@ export async function reconcileTeamHooksForConfig( installedBaseDir: localConfig.scope === 'project' ? (localConfig.projectRoot ?? baseDir) : undefined, scope: localConfig.scope, builtinsOnly, - mainCheckout: await resolveMainCheckoutHooks(localConfig), + mainCheckout: await resolveMainCheckoutHooks(localConfig, teamConfig.toolPaths), }); const copilotExcluded = disabled?.includes(COPILOT_TOOL_ID) ?? false; diff --git a/src/uninstall.ts b/src/uninstall.ts index 55d9a1bbb..bc5243bba 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -69,6 +69,7 @@ import { listQueuesIn } from './utils/pending-learnings.js'; import { log } from './utils/logger.js'; import { askConfirmation } from './utils/prompt.js'; import { getUserHome } from './utils/home.js'; +import { listWorktrees } from './utils/git.js'; import { detectShellProfile, findEnvBlockFor, @@ -581,12 +582,22 @@ async function buildRemovalPlan( const legacyHookScope = resolveLegacyProjectHookScope(localConfig); if (legacyHookScope) hookTargets.push(legacyHookScope); // The project's Claude and Codex team hooks, in the main checkout (#955). - const mainCheckout = await resolveMainCheckoutHooks(localConfig); - if (mainCheckout) { + const mainCheckout = await resolveMainCheckoutHooks(localConfig, teamConfig.toolPaths); + const mainCheckouts = mainCheckout ? [mainCheckout] : []; + // A bare anchor has no shared checkout file. Uninstall removes the shared + // data home, so collect every live workspace's hooks before deleting ownership. + if (mainCheckout?.worktreeScoped) { + for (const root of await listWorktrees(localConfig.projectRoot!)) { + if (root === mainCheckout.root) continue; + const target = await resolveMainCheckoutHooks({ ...localConfig, projectRoot: root }, teamConfig.toolPaths); + if (target?.worktreeScoped) mainCheckouts.push(target); + } + } + for (const target of mainCheckouts) { hookTargets.push({ - baseDir: mainCheckout.root, - manifestPath: mainCheckout.manifestPath, - fileFor: (tool) => mainCheckoutHookFile(mainCheckout, tool), + baseDir: target.root, + manifestPath: target.manifestPath, + fileFor: (tool) => mainCheckoutHookFile(target, tool), }); } // Hook discovery resolves its file name at the same scope as the targets: a diff --git a/src/utils/git.ts b/src/utils/git.ts index 5baab5e10..e71959ff2 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -837,6 +837,8 @@ export async function isDedicatedRepoRoot(repoPath: string): Promise { export interface ProjectAnchors { workspaceRoot: string; projectAnchor: string; + /** The shared identity is a bare repository, not a checkout suitable for project files. */ + projectAnchorIsBare?: boolean; } /** @@ -881,6 +883,7 @@ async function readAnchors(cwd?: string): Promise { const git = createGit(cwd); let toplevel: string; let mainWorktree: string; + let projectAnchorIsBare = false; try { toplevel = (await git.revparse(['--show-toplevel'])).trim(); const list = await git.raw(['worktree', 'list', '--porcelain']); @@ -888,6 +891,7 @@ async function readAnchors(cwd?: string): Promise { // linked worktrees of this repository. const first = list.split('\n').find((l) => l.startsWith('worktree ')); mainWorktree = first ? first.slice('worktree '.length).trim() : ''; + projectAnchorIsBare = list.split('\n\n')[0].split('\n').some((line) => line.trim() === 'bare'); } catch { return null; } @@ -897,7 +901,7 @@ async function readAnchors(cwd?: string): Promise { realpath(toplevel), realpath(mainWorktree), ]); - return { workspaceRoot, projectAnchor }; + return { workspaceRoot, projectAnchor, ...(projectAnchorIsBare ? { projectAnchorIsBare: true } : {}) }; } catch { return null; } From c7d0a467d9bc3d16963c31c442797a87b1cb4353 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 09:13:50 +0200 Subject: [PATCH 10/13] fix(hooks): recover legacy Codex options and orphaned project hooks (#955) --- docs/designs/data-directory-layout.md | 1 + docs/usage-guide.md | 4 +- docs/usage-guide.zh-CN.md | 4 +- skill-data/core/references/troubleshooting.md | 7 +- src/__tests__/codex-trust.test.ts | 77 +++++++++++++++++++ src/__tests__/hooks-reconcile-scope.test.ts | 28 +++++++ src/hooks.ts | 53 +++++++------ 7 files changed, 146 insertions(+), 28 deletions(-) diff --git a/docs/designs/data-directory-layout.md b/docs/designs/data-directory-layout.md index 0e30bed26..e1151d183 100644 --- a/docs/designs/data-directory-layout.md +++ b/docs/designs/data-directory-layout.md @@ -499,6 +499,7 @@ every checkout, so that is where they live now: │ settings, shared by every checkout (#955; the built-in hooks stay in HOME) │ project toolPaths choose the files; Claude uses settings.local.json beside its settings file │ Codex records event, matcher-group position and complete rendered entry; unique definitions recover moved entries +│ legacy ownership matches event/matcher/command uniquely, ignoring unrecorded timeout/context options └── workspaces// ├── managed-main-checkout-hooks.json bare repositories only: this workspace owns its Claude / Codex team-hook files and trust target ├── managed-mcp.json managedMcpManifestPath, one per checkout; Copilot placement is true for bare, false for keyed, absent when unproven diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 89da32ce0..d91df98ab 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1918,7 +1918,7 @@ teamai hooks remove # Remove `hooks list` prints the built-in set per tool, because the set is not universal: Copilot also gets `SessionEnd`, Claude Code, Codex, CodeBuddy and Qoder also get `SubagentStop`, the Codex family also gets `SubagentStart` (the project's team rules and instructions for a spawned subagent), OMP's extension covers four events without the `Skill` / `TodoWrite` matchers, OpenClaw maps only `SessionStart` + `UserPromptSubmit`, and Hermes only `SessionStart`. Tools the hook pipeline installs nothing for (e.g. JoyCode) are omitted, and so is Kiro — its `SessionStart` command is embedded as `hooks.agentSpawn` by the agent sync, so it exists only for the agents you actually synced. -The inject and remove commands only touch tools you actually have installed (i.e. whose `~/./` root directory already exists). They never create root directories for tools listed in `toolPaths` but not installed. +The inject and remove commands only touch tools you actually have installed (i.e. whose `~/./` root directory already exists). They never create root directories for tools listed in `toolPaths` but not installed. Removal also checks existing Claude/Codex main-checkout hook files when the HOME root is missing or relocated. In non-self project scope, `hooks remove` removes this checkout's gated team hooks from HOME and Claude/Codex team hooks from the main checkout. Other projects' gated team hooks stay in HOME; shared built-in hooks are removed. @@ -1926,7 +1926,7 @@ On Windows, the built-in hook dispatch commands that shell out through bash (e.g Cursor also loads `~/.claude/settings.json`, and Copilot CLI loads a trusted project's `.claude/settings.json` (self mode writes hooks there; Copilot does not load `~/.claude/settings.json`). `hook-dispatch --tool claude` exits only when that other host's own teamai hooks are on disk: `~/.cursor/hooks.json` or `$CURSOR_PROJECT_DIR/.cursor/hooks.json` contains `--tool cursor`, or `$COPILOT_PROJECT_DIR/.github/hooks/teamai.json` contains `--tool copilot`. Team hook commands written for `claude` use the same check. A setup with only Claude keeps running inside Cursor, because there is no second copy. `COPILOT_CLI` is not a signal: Copilot sets it on every subprocess, including a Claude session started from its shell. Claude Code sets neither `CURSOR_VERSION` nor `COPILOT_PROJECT_DIR`. Run `teamai pull` or `teamai hooks inject` again so an already installed team hook picks up the guard. -> **Codex hook trust** — Codex (the OpenAI / ChatGPT Codex app, tool id `codex`) runs a non-managed hook only once it is trusted, and skips an untrusted or changed one without a word; it reads a project's `.codex/` only when the project is trusted. So after every write of a Codex hooks file (`init`, every `pull` including the session-start one, `teamai hooks inject`) teamai trusts exactly the hooks it wrote, through `codex app-server` — the same call Codex's `/hooks` trust prompt makes. Your own hooks in the same file are left alone, even when their commands equal a team hook. Codex ownership records include the event, position and complete generated entry; trust selects that exact Codex key. If unrelated entries move it, teamai recovers ownership only when the complete definition matches uniquely. Unrecorded or ambiguous legacy team-hook copies are preserved. In a project, teamai also trusts the main checkout when Codex has to read teamai's hooks or MCP servers from its `.codex/`; for a bare repository, teamai writes and trusts the current worktree instead. A project you marked untrusted in Codex stays so, and teamai says so. Trust written by a session-start pull applies from the next Codex session: the running one already loaded its hooks. A linked worktree reads the main checkout's `.codex/hooks.json` only once it has a `.codex/` directory, which the Codex session-start hook creates, so a new worktree gets the team hooks from its second Codex session; the built-in hooks live in `~/.codex/hooks.json` and run from the first. To trust them yourself, set `codexTrustEnabled: false` in `config.yaml`. `init` and `hooks inject` print a reminder to trust them in `/hooks` or Settings → Hooks when `codex` is absent or its app-server fails. An interactive pull warns on app-server failure and stays quiet when `codex` is absent; silent pulls record the result in the debug log. `teamai doctor` asks Codex which teamai hooks it will not run and names them. +> **Codex hook trust** — Codex (the OpenAI / ChatGPT Codex app, tool id `codex`) runs a non-managed hook only once it is trusted, and skips an untrusted or changed one without a word; it reads a project's `.codex/` only when the project is trusted. So after every write of a Codex hooks file (`init`, every `pull` including the session-start one, `teamai hooks inject`) teamai trusts exactly the hooks it wrote, through `codex app-server` — the same call Codex's `/hooks` trust prompt makes. Your own hooks in the same file are left alone, even when their commands equal a team hook. Codex ownership records include the event, position and complete generated entry; trust selects that exact Codex key. If unrelated entries move it, teamai recovers ownership only when the complete definition matches uniquely. Legacy manifests recorded only event, matcher and command, so a unique match on those fields recovers ownership even with `timeout` or `additionalContextLimit`. Unrecorded or ambiguous legacy team-hook copies are preserved. In a project, teamai also trusts the main checkout when Codex has to read teamai's hooks or MCP servers from its `.codex/`; for a bare repository, teamai writes and trusts the current worktree instead. A project you marked untrusted in Codex stays so, and teamai says so. Trust written by a session-start pull applies from the next Codex session: the running one already loaded its hooks. A linked worktree reads the main checkout's `.codex/hooks.json` only once it has a `.codex/` directory, which the Codex session-start hook creates, so a new worktree gets the team hooks from its second Codex session; the built-in hooks live in `~/.codex/hooks.json` and run from the first. To trust them yourself, set `codexTrustEnabled: false` in `config.yaml`. `init` and `hooks inject` print a reminder to trust them in `/hooks` or Settings → Hooks when `codex` is absent or its app-server fails. An interactive pull warns on app-server failure and stays quiet when `codex` is absent; silent pulls record the result in the debug log. `teamai doctor` asks Codex which teamai hooks it will not run and names them. ### Team Hooks Declaration diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index f3c9c5f76..e630a5a5b 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1783,7 +1783,7 @@ teamai hooks remove # 移除 `hooks list` 按工具分别列出内置 hooks,因为各工具的集合并不相同:Copilot 额外有 `SessionEnd`,Claude Code、Codex、CodeBuddy 和 Qoder 额外有 `SubagentStop`,Codex 系工具还额外有 `SubagentStart`(为其启动的子 agent 提供项目的团队 rule 和指令),OMP 扩展覆盖四个事件且没有 `Skill` / `TodoWrite` matcher,OpenClaw 只映射 `SessionStart` + `UserPromptSubmit`,Hermes 只有 `SessionStart`。hook 注入流程不会为其安装任何内置 hook 的工具(如 JoyCode)不会列出;Kiro 也不列出——它的 `SessionStart` 由 agent 同步以 `hooks.agentSpawn` 形式内嵌,只存在于你实际同步过的 agent 中。 -inject 和 remove 只会操作你实际已安装的工具(即 `~/./` 根目录已存在的工具)。对于 `toolPaths` 中已配置但未安装的工具,命令不会为其凭空创建根目录。 +inject 和 remove 只会操作你实际已安装的工具(即 `~/./` 根目录已存在的工具)。对于 `toolPaths` 中已配置但未安装的工具,命令不会为其凭空创建根目录。HOME 根目录缺失或已迁移时,remove 仍会检查主 checkout 中现存的 Claude/Codex hook 文件。 非-self 的 project scope 中,`hooks remove` 会移除 HOME 中当前 checkout 的门控团队 hooks,以及主 checkout 中 Claude/Codex 的团队 hooks。其他项目的门控团队 hooks 保留在 HOME;共享的内置 hooks 会被移除。 @@ -1791,7 +1791,7 @@ inject 和 remove 只会操作你实际已安装的工具(即 `~/./` 根 Cursor 也会加载 `~/.claude/settings.json`。Copilot CLI 会加载受信任项目里的 `.claude/settings.json`(self mode 把 hook 写在项目里;Copilot 不加载 `~/.claude/settings.json`)。只有另一边的 teamai hook 已经在磁盘上时,`hook-dispatch --tool claude` 才会退出:`~/.cursor/hooks.json` 或 `$CURSOR_PROJECT_DIR/.cursor/hooks.json` 含有 `--tool cursor`,或 `$COPILOT_PROJECT_DIR/.github/hooks/teamai.json` 含有 `--tool copilot`。写给 `claude` 的团队 hook 命令用同一判断。只启用了 Claude 时,Cursor 里这份 hook 照常运行,因为没有第二份可以接替。`COPILOT_CLI` 不能当信号:Copilot 会给每个子进程设置它,包括从它的 shell 里启动的 Claude。Claude Code 不会设置 `CURSOR_VERSION` 或 `COPILOT_PROJECT_DIR`。已经装好的团队 hook 需要再跑一次 `teamai pull` 或 `teamai hooks inject`,才会带上这个判断。 -> **Codex hook 信任** — Codex(OpenAI / ChatGPT Codex 应用,工具 id 为 `codex`)只运行已信任的非托管 hook,未信任或已变更的 hook 会被静默跳过;且只有项目被信任时才读取其 `.codex/`。因此每次写入 Codex hooks 文件后(`init`、每次 `pull`(含 SessionStart 触发的 pull)、`teamai hooks inject`),teamai 都会通过 `codex app-server` 信任它写入的那些 hook——与 Codex `/hooks` 信任提示调用的是同一接口。同一文件里你自己的 hook 不受影响,即使命令与团队 hook 相同。Codex 所有权记录包含事件、位置和完整生成条目,信任操作只选择对应的 Codex key。其他条目移动它的位置时,仅在完整定义唯一匹配时恢复所有权。没有所有权记录或无法区分的旧团队 hook 副本会保留。在项目中,当 Codex 需要从主 checkout 的 `.codex/` 读取 teamai 的 hooks 或 MCP servers 时,teamai 也会信任该主 checkout;bare 仓库则在当前 worktree 写入并信任。你在 Codex 中标记为不信任的项目保持不变,teamai 会提示。SessionStart 触发的 pull 写入的信任从下一个 Codex 会话起生效:当前会话已加载了它的 hooks。linked worktree 只有在存在 `.codex/` 目录时才读取主 checkout 的 `.codex/hooks.json`,该目录由 Codex 的 SessionStart hook 创建,因此新 worktree 从第二个 Codex 会话起获得团队 hooks;内置 hooks 位于 `~/.codex/hooks.json`,从第一个会话起就运行。若要自行信任,在 `config.yaml` 中设置 `codexTrustEnabled: false`。PATH 中没有 `codex` 或 app-server 失败时,`init` 和 `hooks inject` 会提示你在 `/hooks` 或 Settings → Hooks 中信任。交互式 pull 仅在 app-server 失败时警告,缺少 `codex` 时保持静默;silent pull 将结果记录在 debug 日志中。`teamai doctor` 会向 Codex 查询哪些 teamai hooks 不会运行并逐一列出。 +> **Codex hook 信任** — Codex(OpenAI / ChatGPT Codex 应用,工具 id 为 `codex`)只运行已信任的非托管 hook,未信任或已变更的 hook 会被静默跳过;且只有项目被信任时才读取其 `.codex/`。因此每次写入 Codex hooks 文件后(`init`、每次 `pull`(含 SessionStart 触发的 pull)、`teamai hooks inject`),teamai 都会通过 `codex app-server` 信任它写入的那些 hook——与 Codex `/hooks` 信任提示调用的是同一接口。同一文件里你自己的 hook 不受影响,即使命令与团队 hook 相同。Codex 所有权记录包含事件、位置和完整生成条目,信任操作只选择对应的 Codex key。其他条目移动它的位置时,仅在完整定义唯一匹配时恢复所有权。旧 manifest 只记录事件、matcher 和命令,因此这些字段唯一匹配时,即使 hook 包含 `timeout` 或 `additionalContextLimit`,也可恢复所有权。没有所有权记录或无法区分的旧团队 hook 副本会保留。在项目中,当 Codex 需要从主 checkout 的 `.codex/` 读取 teamai 的 hooks 或 MCP servers 时,teamai 也会信任该主 checkout;bare 仓库则在当前 worktree 写入并信任。你在 Codex 中标记为不信任的项目保持不变,teamai 会提示。SessionStart 触发的 pull 写入的信任从下一个 Codex 会话起生效:当前会话已加载了它的 hooks。linked worktree 只有在存在 `.codex/` 目录时才读取主 checkout 的 `.codex/hooks.json`,该目录由 Codex 的 SessionStart hook 创建,因此新 worktree 从第二个 Codex 会话起获得团队 hooks;内置 hooks 位于 `~/.codex/hooks.json`,从第一个会话起就运行。若要自行信任,在 `config.yaml` 中设置 `codexTrustEnabled: false`。PATH 中没有 `codex` 或 app-server 失败时,`init` 和 `hooks inject` 会提示你在 `/hooks` 或 Settings → Hooks 中信任。交互式 pull 仅在 app-server 失败时警告,缺少 `codex` 时保持静默;silent pull 将结果记录在 debug 日志中。`teamai doctor` 会向 Codex 查询哪些 teamai hooks 不会运行并逐一列出。 ### 团队 Hooks 声明 diff --git a/skill-data/core/references/troubleshooting.md b/skill-data/core/references/troubleshooting.md index 2c2aed808..844e838bf 100644 --- a/skill-data/core/references/troubleshooting.md +++ b/skill-data/core/references/troubleshooting.md @@ -32,6 +32,8 @@ This is the #1 onboarding issue. In order: settings (e.g. `~/.claude/settings.json`), not the project folder; the team's own hooks for Claude Code and Codex go to the main checkout (`.claude/settings.local.json`, `.codex/hooks.json`). That is intentional. + Removal also checks existing Claude/Codex main-checkout hook files when the + HOME tool root is missing or relocated. In project scope, `teamai hooks remove` preserves other projects' gated team hooks in HOME, while removing the shared built-in hooks. If you initialized project scope but expected machine-wide resources, re-run @@ -175,8 +177,9 @@ then reopen a session. A new linked worktree gets the team hooks from its second Codex session (the first creates its `.codex/`). Member hooks with the same command are preserved and remain untouched by automatic trust. Codex ownership uses the recorded event, position and complete entry. A moved entry is recovered only by a -unique full-definition match; unrecorded or ambiguous legacy team-hook copies -are preserved rather than claimed by command. Project hook paths follow `toolPaths`; +unique full-definition match. Legacy records recover only a unique event, matcher +and command match; `timeout` and `additionalContextLimit` were not recorded. +Unrecorded or ambiguous legacy team-hook copies are preserved. Project hook paths follow `toolPaths`; Claude uses `settings.local.json` beside its configured settings file. A custom Codex path that Codex does not load is reported as `not loaded` by doctor. diff --git a/src/__tests__/codex-trust.test.ts b/src/__tests__/codex-trust.test.ts index 097258c5d..1dd7ff7c8 100644 --- a/src/__tests__/codex-trust.test.ts +++ b/src/__tests__/codex-trust.test.ts @@ -177,6 +177,83 @@ describe('Codex hook ownership', () => { expect((await fse.readJson(file)).hooks[event]).toEqual(memberGroups); }); + it.each([ + { timeout: 30 }, + { additionalContextLimit: 0 }, + { timeout: 30, additionalContextLimit: 0 }, + ])('migrates legacy Codex ownership with unrecorded options %j', async (options) => { + const file = path.join(codexHome(), 'hooks.json'); + const legacy = { matcher: 'Bash', hooks: [{ type: 'command', command: 'npm run lint', ...options }] }; + const member = { matcher: 'Write', hooks: [{ type: 'command', command: 'npm run lint', timeout: 17 }] }; + await fse.writeJson(file, { hooks: { PreToolUse: [member, legacy], Stop: [legacy] } }); + await fse.outputJson(path.join(home, '.teamai', 'managed-hooks.json'), { + codex: [{ id: 'lint', event: 'PreToolUse', matcher: 'Bash', command: 'npm run lint' }], + }); + const desiredOptions = options.timeout === undefined ? {} : { timeout: options.timeout }; + const yaml = LINT_HOOK + ' matcher: Bash\n' + + (options.timeout === undefined ? '' : ` timeout: ${options.timeout}\n`); + await writeYaml(yaml); + + await writeAndTrust(userConfig()); + + expect((await fse.readJson(file)).hooks.PreToolUse).toEqual([ + member, { matcher: 'Bash', hooks: [{ type: 'command', command: 'npm run lint', ...desiredOptions }] }, + ]); + expect((await fse.readJson(file)).hooks.Stop[0]).toEqual(legacy); + expect(trustedKeys()).toContain(`${file}:pre_tool_use:1:0`); + expect(trustedKeys()).not.toContain(`${file}:pre_tool_use:0:0`); + expect(trustedKeys()).not.toContain(`${file}:stop:0:0`); + await writeYaml(yaml.replace('npm run lint', 'npm run lint:fix')); + await writeAndTrust(userConfig()); + expect((await fse.readJson(file)).hooks.PreToolUse).toEqual([ + member, { matcher: 'Bash', hooks: [{ type: 'command', command: 'npm run lint:fix', ...desiredOptions }] }, + ]); + await writeAndTrust(userConfig(), { removeAll: true }); + expect((await fse.readJson(file)).hooks.PreToolUse).toEqual([member]); + expect((await fse.readJson(file)).hooks.Stop).toEqual([legacy]); + }); + + it('trusts and removes a unique legacy hook before its first upgraded reconcile', async () => { + const file = path.join(codexHome(), 'hooks.json'); + const member = { matcher: 'Write', hooks: [{ type: 'command', command: 'npm run lint' }] }; + const legacy = { hooks: [{ type: 'command', command: 'npm run lint', timeout: 30, additionalContextLimit: 0 }] }; + await fse.writeJson(file, { hooks: { PreToolUse: [member, legacy] } }); + await fse.outputJson(path.join(home, '.teamai', 'managed-hooks.json'), { + codex: [{ id: 'lint', event: 'PreToolUse', command: 'npm run lint' }], + }); + + expect(await trustCodexForScope(teamConfig, userConfig())).toEqual({ kind: 'trusted', hooks: 1 }); + expect(trustedKeys()).toEqual([`${file}:pre_tool_use:1:0`]); + await writeAndTrust(userConfig(), { removeAll: true }); + + expect((await fse.readJson(file)).hooks.PreToolUse).toEqual([member]); + }); + + it('preserves legacy option collisions and multi-handler member groups', async () => { + await writeYaml(LINT_HOOK + ' matcher: Bash\n'); + const file = path.join(codexHome(), 'hooks.json'); + const groups = [ + { matcher: 'Bash', hooks: [{ type: 'command', command: 'npm run lint', timeout: 30 }] }, + { matcher: 'Bash', hooks: [{ type: 'command', command: 'npm run lint', additionalContextLimit: 0 }] }, + { matcher: 'Bash', hooks: [{ type: 'command', command: 'npm run lint' }, { type: 'command', command: 'echo member' }] }, + ]; + await fse.writeJson(file, { hooks: { PreToolUse: groups } }); + await fse.outputJson(path.join(home, '.teamai', 'managed-hooks.json'), { + codex: [{ id: 'lint', event: 'PreToolUse', matcher: 'Bash', command: 'npm run lint' }], + }); + const memberKeys = (await codexEntries(file)).map((e) => e.key); + + expect(await trustCodexForScope(teamConfig, userConfig())).toBeUndefined(); + await writeAndTrust(userConfig()); + + expect((await fse.readJson(file)).hooks.PreToolUse).toEqual([ + ...groups, { matcher: 'Bash', hooks: [{ type: 'command', command: 'npm run lint' }] }, + ]); + expect(trustedKeys().some((key) => memberKeys.includes(key))).toBe(false); + await writeAndTrust(userConfig(), { removeAll: true }); + expect((await fse.readJson(file)).hooks.PreToolUse).toEqual(groups); + }); + it('keeps ambiguous legacy entries instead of claiming every identical command', async () => { await writeYaml(LINT_HOOK); const file = path.join(codexHome(), 'hooks.json'); diff --git a/src/__tests__/hooks-reconcile-scope.test.ts b/src/__tests__/hooks-reconcile-scope.test.ts index 42f0b3409..f4a6f1cca 100644 --- a/src/__tests__/hooks-reconcile-scope.test.ts +++ b/src/__tests__/hooks-reconcile-scope.test.ts @@ -624,6 +624,34 @@ describe('reconcileTeamHooksForConfig — team hooks in the main checkout', () = expect(await read()).toEqual(before); }); + it('removes main-checkout hooks without recreating missing HOME roots', async () => { + await writeYaml(STOP_LINT); + const { main, worktree } = await mainWithWorktree(); + const cfg = { ...localConfig(), projectRoot: worktree }; + try { + await reconcileTeamHooksForConfig(teamConfig, cfg); + const files = [path.join(main, '.claude', 'settings.local.json'), path.join(main, '.codex', 'hooks.json')]; + const member = { hooks: [{ type: 'command', command: 'echo member' }] }; + for (const file of files) { + const json = await fse.readJson(file); + json.hooks.Stop.push(member); + await fse.writeJson(file, json); + } + await fse.remove(path.join(home, '.claude')); + await fse.remove(path.join(home, '.codex')); + + await reconcileTeamHooksForConfig(teamConfig, cfg, { removeAll: true }); + + for (const file of files) expect((await fse.readJson(file)).hooks.Stop).toEqual([member]); + for (const root of [home, worktree]) { + expect(await fse.pathExists(path.join(root, '.claude'))).toBe(false); + expect(await fse.pathExists(path.join(root, '.codex'))).toBe(false); + } + } finally { + await fse.remove(worktree); + } + }); + it('removeAll clears the main checkout\'s team hooks too', async () => { await writeYaml(STOP_LINT); await reconcileTeamHooksForConfig(teamConfig, localConfig()); diff --git a/src/hooks.ts b/src/hooks.ts index 21d50b72b..e459af929 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -1025,16 +1025,20 @@ async function reconcileCodexFormat( return records; } -/** Recover a moved entry only when its complete recorded definition is unique. */ +/** Recover only a unique match on the definition or fields the manifest recorded. */ function ownsCodexEntry(record: ManagedHookRecord, event: string, index: number, entries: CodexHookMatcher[]): boolean { if (record.event !== event) return false; const entry = entries[index]; if (record.codexEntry && record.codexEntryIndex === index && isDeepStrictEqual(entry, record.codexEntry)) return true; - const recorded = record.codexEntry ?? { - ...(record.matcher ? { matcher: record.matcher } : {}), - hooks: [{ type: 'command', command: record.command }], - }; - return isDeepStrictEqual(entry, recorded) && entries.filter((candidate) => isDeepStrictEqual(candidate, recorded)).length === 1; + // Legacy manifests omitted timeout and additionalContextLimit. Requiring + // those unrecorded options to be absent would orphan hooks on upgrade. + const matches = record.codexEntry + ? (candidate: CodexHookMatcher) => isDeepStrictEqual(candidate, record.codexEntry) + : (candidate: CodexHookMatcher) => candidate.matcher === record.matcher + && candidate.hooks?.length === 1 + && candidate.hooks[0].type === 'command' + && candidate.hooks[0].command === record.command; + return matches(entry) && entries.filter(matches).length === 1; } // ─── ZCode (~/.zcode/cli/config.json) reconcile ───────────── @@ -1917,31 +1921,36 @@ export async function reconcileHooksToAllTools( const installedRoot = opts.installedBaseDir ? path.join(opts.installedBaseDir, toolInstallRoot(paths.settings)) : toolRoot; - if (!await pathExists(toolRoot) && !await pathExists(installedRoot)) continue; + const installed = await pathExists(toolRoot) || await pathExists(installedRoot); + const mainFile = mainCheckoutHookFile(opts.mainCheckout, tool); + // Existing main-checkout hooks can be removed after HOME was deleted or + // relocated. Do not recreate the missing HOME root just to remove them. + if (!installed && !(opts.removeAll && mainFile)) continue; const settingsPath = path.join(baseDir, paths.settings); const settingsFileKey = path.resolve(settingsPath); if (claimedSettingsFiles.has(settingsFileKey)) continue; claimedSettingsFiles.add(settingsFileKey); try { - if (await skipInstalled(settingsPath, tool)) continue; - const mainFile = mainCheckoutHookFile(opts.mainCheckout, tool); - if (mainFile && opts.mainCheckout && opts.teamHookProjectRoot && !opts.removeAll) { - // HOME keeps this tool's built-ins only: the project's team hooks live - // in the main checkout, and the gated copies of them are removed. - for (const root of await staleCheckoutGateRoots(teamManifestPath, tool, opts.teamHookProjectRoot, opts.mainCheckout)) { - await reconcileHooks(settingsPath, tool, [], { + if (installed && await skipInstalled(settingsPath, tool)) continue; + if (installed) { + if (mainFile && opts.mainCheckout && opts.teamHookProjectRoot && !opts.removeAll) { + // HOME keeps this tool's built-ins only: the project's team hooks live + // in the main checkout, and the gated copies of them are removed. + for (const root of await staleCheckoutGateRoots(teamManifestPath, tool, opts.teamHookProjectRoot, opts.mainCheckout)) { + await reconcileHooks(settingsPath, tool, [], { + manifestPath: teamManifestPath, + builtinOverride: opts.builtinOverride, + teamHookProjectRoot: root, + }); + } + } else { + await reconcileHooks(settingsPath, tool, defs, { manifestPath: teamManifestPath, + removeAll: opts.removeAll, builtinOverride: opts.builtinOverride, - teamHookProjectRoot: root, + teamHookProjectRoot: opts.teamHookProjectRoot, }); } - } else { - await reconcileHooks(settingsPath, tool, defs, { - manifestPath: teamManifestPath, - removeAll: opts.removeAll, - builtinOverride: opts.builtinOverride, - teamHookProjectRoot: opts.teamHookProjectRoot, - }); } // With the team hooks unresolved, the ones installed there are kept. if (mainFile && opts.mainCheckout && !opts.builtinsOnly) { From a9ab3f21c49218865efff58edc7cae1b162cf3ca Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 09:38:47 +0200 Subject: [PATCH 11/13] fix(hooks): migrate pre-370 Codex hook ownership (#955) --- docs/designs/data-directory-layout.md | 1 + docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- skill-data/core/references/troubleshooting.md | 2 + src/__tests__/codex-trust.test.ts | 8 ++++ src/__tests__/hooks-reconcile-scope.test.ts | 43 +++++++++++++++++++ src/hooks.ts | 29 ++++++++++--- 7 files changed, 78 insertions(+), 9 deletions(-) diff --git a/docs/designs/data-directory-layout.md b/docs/designs/data-directory-layout.md index e1151d183..11d9c5744 100644 --- a/docs/designs/data-directory-layout.md +++ b/docs/designs/data-directory-layout.md @@ -500,6 +500,7 @@ every checkout, so that is where they live now: │ project toolPaths choose the files; Claude uses settings.local.json beside its settings file │ Codex records event, matcher-group position and complete rendered entry; unique definitions recover moved entries │ legacy ownership matches event/matcher/command uniquely, ignoring unrecorded timeout/context options +│ pre-#370 Codex ownership is imported from
/.teamai/managed-hooks.json before reconcile/removal └── workspaces// ├── managed-main-checkout-hooks.json bare repositories only: this workspace owns its Claude / Codex team-hook files and trust target ├── managed-mcp.json managedMcpManifestPath, one per checkout; Copilot placement is true for bare, false for keyed, absent when unproven diff --git a/docs/usage-guide.md b/docs/usage-guide.md index d91df98ab..f9b1f6a30 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1926,7 +1926,7 @@ On Windows, the built-in hook dispatch commands that shell out through bash (e.g Cursor also loads `~/.claude/settings.json`, and Copilot CLI loads a trusted project's `.claude/settings.json` (self mode writes hooks there; Copilot does not load `~/.claude/settings.json`). `hook-dispatch --tool claude` exits only when that other host's own teamai hooks are on disk: `~/.cursor/hooks.json` or `$CURSOR_PROJECT_DIR/.cursor/hooks.json` contains `--tool cursor`, or `$COPILOT_PROJECT_DIR/.github/hooks/teamai.json` contains `--tool copilot`. Team hook commands written for `claude` use the same check. A setup with only Claude keeps running inside Cursor, because there is no second copy. `COPILOT_CLI` is not a signal: Copilot sets it on every subprocess, including a Claude session started from its shell. Claude Code sets neither `CURSOR_VERSION` nor `COPILOT_PROJECT_DIR`. Run `teamai pull` or `teamai hooks inject` again so an already installed team hook picks up the guard. -> **Codex hook trust** — Codex (the OpenAI / ChatGPT Codex app, tool id `codex`) runs a non-managed hook only once it is trusted, and skips an untrusted or changed one without a word; it reads a project's `.codex/` only when the project is trusted. So after every write of a Codex hooks file (`init`, every `pull` including the session-start one, `teamai hooks inject`) teamai trusts exactly the hooks it wrote, through `codex app-server` — the same call Codex's `/hooks` trust prompt makes. Your own hooks in the same file are left alone, even when their commands equal a team hook. Codex ownership records include the event, position and complete generated entry; trust selects that exact Codex key. If unrelated entries move it, teamai recovers ownership only when the complete definition matches uniquely. Legacy manifests recorded only event, matcher and command, so a unique match on those fields recovers ownership even with `timeout` or `additionalContextLimit`. Unrecorded or ambiguous legacy team-hook copies are preserved. In a project, teamai also trusts the main checkout when Codex has to read teamai's hooks or MCP servers from its `.codex/`; for a bare repository, teamai writes and trusts the current worktree instead. A project you marked untrusted in Codex stays so, and teamai says so. Trust written by a session-start pull applies from the next Codex session: the running one already loaded its hooks. A linked worktree reads the main checkout's `.codex/hooks.json` only once it has a `.codex/` directory, which the Codex session-start hook creates, so a new worktree gets the team hooks from its second Codex session; the built-in hooks live in `~/.codex/hooks.json` and run from the first. To trust them yourself, set `codexTrustEnabled: false` in `config.yaml`. `init` and `hooks inject` print a reminder to trust them in `/hooks` or Settings → Hooks when `codex` is absent or its app-server fails. An interactive pull warns on app-server failure and stays quiet when `codex` is absent; silent pulls record the result in the debug log. `teamai doctor` asks Codex which teamai hooks it will not run and names them. +> **Codex hook trust** — Codex (the OpenAI / ChatGPT Codex app, tool id `codex`) runs a non-managed hook only once it is trusted, and skips an untrusted or changed one without a word; it reads a project's `.codex/` only when the project is trusted. So after every write of a Codex hooks file (`init`, every `pull` including the session-start one, `teamai hooks inject`) teamai trusts exactly the hooks it wrote, through `codex app-server` — the same call Codex's `/hooks` trust prompt makes. Your own hooks in the same file are left alone, even when their commands equal a team hook. Codex ownership records include the event, position and complete generated entry; trust selects that exact Codex key. If unrelated entries move it, teamai recovers ownership only when the complete definition matches uniquely. Legacy manifests recorded only event, matcher and command, so a unique match on those fields recovers ownership even with `timeout` or `additionalContextLimit`. For pre-#370 project Codex hooks, teamai imports ownership from the main checkout's `.teamai/managed-hooks.json` before reconciling the same file with the new manifest, including direct removal. Unrecorded or ambiguous legacy team-hook copies are preserved. In a project, teamai also trusts the main checkout when Codex has to read teamai's hooks or MCP servers from its `.codex/`; for a bare repository, teamai writes and trusts the current worktree instead. A project you marked untrusted in Codex stays so, and teamai says so. Trust written by a session-start pull applies from the next Codex session: the running one already loaded its hooks. A linked worktree reads the main checkout's `.codex/hooks.json` only once it has a `.codex/` directory, which the Codex session-start hook creates, so a new worktree gets the team hooks from its second Codex session; the built-in hooks live in `~/.codex/hooks.json` and run from the first. To trust them yourself, set `codexTrustEnabled: false` in `config.yaml`. `init` and `hooks inject` print a reminder to trust them in `/hooks` or Settings → Hooks when `codex` is absent or its app-server fails. An interactive pull warns on app-server failure and stays quiet when `codex` is absent; silent pulls record the result in the debug log. `teamai doctor` asks Codex which teamai hooks it will not run and names them. ### Team Hooks Declaration diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index e630a5a5b..4661abcd1 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1791,7 +1791,7 @@ inject 和 remove 只会操作你实际已安装的工具(即 `~/./` 根 Cursor 也会加载 `~/.claude/settings.json`。Copilot CLI 会加载受信任项目里的 `.claude/settings.json`(self mode 把 hook 写在项目里;Copilot 不加载 `~/.claude/settings.json`)。只有另一边的 teamai hook 已经在磁盘上时,`hook-dispatch --tool claude` 才会退出:`~/.cursor/hooks.json` 或 `$CURSOR_PROJECT_DIR/.cursor/hooks.json` 含有 `--tool cursor`,或 `$COPILOT_PROJECT_DIR/.github/hooks/teamai.json` 含有 `--tool copilot`。写给 `claude` 的团队 hook 命令用同一判断。只启用了 Claude 时,Cursor 里这份 hook 照常运行,因为没有第二份可以接替。`COPILOT_CLI` 不能当信号:Copilot 会给每个子进程设置它,包括从它的 shell 里启动的 Claude。Claude Code 不会设置 `CURSOR_VERSION` 或 `COPILOT_PROJECT_DIR`。已经装好的团队 hook 需要再跑一次 `teamai pull` 或 `teamai hooks inject`,才会带上这个判断。 -> **Codex hook 信任** — Codex(OpenAI / ChatGPT Codex 应用,工具 id 为 `codex`)只运行已信任的非托管 hook,未信任或已变更的 hook 会被静默跳过;且只有项目被信任时才读取其 `.codex/`。因此每次写入 Codex hooks 文件后(`init`、每次 `pull`(含 SessionStart 触发的 pull)、`teamai hooks inject`),teamai 都会通过 `codex app-server` 信任它写入的那些 hook——与 Codex `/hooks` 信任提示调用的是同一接口。同一文件里你自己的 hook 不受影响,即使命令与团队 hook 相同。Codex 所有权记录包含事件、位置和完整生成条目,信任操作只选择对应的 Codex key。其他条目移动它的位置时,仅在完整定义唯一匹配时恢复所有权。旧 manifest 只记录事件、matcher 和命令,因此这些字段唯一匹配时,即使 hook 包含 `timeout` 或 `additionalContextLimit`,也可恢复所有权。没有所有权记录或无法区分的旧团队 hook 副本会保留。在项目中,当 Codex 需要从主 checkout 的 `.codex/` 读取 teamai 的 hooks 或 MCP servers 时,teamai 也会信任该主 checkout;bare 仓库则在当前 worktree 写入并信任。你在 Codex 中标记为不信任的项目保持不变,teamai 会提示。SessionStart 触发的 pull 写入的信任从下一个 Codex 会话起生效:当前会话已加载了它的 hooks。linked worktree 只有在存在 `.codex/` 目录时才读取主 checkout 的 `.codex/hooks.json`,该目录由 Codex 的 SessionStart hook 创建,因此新 worktree 从第二个 Codex 会话起获得团队 hooks;内置 hooks 位于 `~/.codex/hooks.json`,从第一个会话起就运行。若要自行信任,在 `config.yaml` 中设置 `codexTrustEnabled: false`。PATH 中没有 `codex` 或 app-server 失败时,`init` 和 `hooks inject` 会提示你在 `/hooks` 或 Settings → Hooks 中信任。交互式 pull 仅在 app-server 失败时警告,缺少 `codex` 时保持静默;silent pull 将结果记录在 debug 日志中。`teamai doctor` 会向 Codex 查询哪些 teamai hooks 不会运行并逐一列出。 +> **Codex hook 信任** — Codex(OpenAI / ChatGPT Codex 应用,工具 id 为 `codex`)只运行已信任的非托管 hook,未信任或已变更的 hook 会被静默跳过;且只有项目被信任时才读取其 `.codex/`。因此每次写入 Codex hooks 文件后(`init`、每次 `pull`(含 SessionStart 触发的 pull)、`teamai hooks inject`),teamai 都会通过 `codex app-server` 信任它写入的那些 hook——与 Codex `/hooks` 信任提示调用的是同一接口。同一文件里你自己的 hook 不受影响,即使命令与团队 hook 相同。Codex 所有权记录包含事件、位置和完整生成条目,信任操作只选择对应的 Codex key。其他条目移动它的位置时,仅在完整定义唯一匹配时恢复所有权。旧 manifest 只记录事件、matcher 和命令,因此这些字段唯一匹配时,即使 hook 包含 `timeout` 或 `additionalContextLimit`,也可恢复所有权。对于 #370 之前的项目 Codex hooks,teamai 先从主 checkout 的 `.teamai/managed-hooks.json` 导入所有权,再用新 manifest 同步同一个文件;直接移除时也如此。没有所有权记录或无法区分的旧团队 hook 副本会保留。在项目中,当 Codex 需要从主 checkout 的 `.codex/` 读取 teamai 的 hooks 或 MCP servers 时,teamai 也会信任该主 checkout;bare 仓库则在当前 worktree 写入并信任。你在 Codex 中标记为不信任的项目保持不变,teamai 会提示。SessionStart 触发的 pull 写入的信任从下一个 Codex 会话起生效:当前会话已加载了它的 hooks。linked worktree 只有在存在 `.codex/` 目录时才读取主 checkout 的 `.codex/hooks.json`,该目录由 Codex 的 SessionStart hook 创建,因此新 worktree 从第二个 Codex 会话起获得团队 hooks;内置 hooks 位于 `~/.codex/hooks.json`,从第一个会话起就运行。若要自行信任,在 `config.yaml` 中设置 `codexTrustEnabled: false`。PATH 中没有 `codex` 或 app-server 失败时,`init` 和 `hooks inject` 会提示你在 `/hooks` 或 Settings → Hooks 中信任。交互式 pull 仅在 app-server 失败时警告,缺少 `codex` 时保持静默;silent pull 将结果记录在 debug 日志中。`teamai doctor` 会向 Codex 查询哪些 teamai hooks 不会运行并逐一列出。 ### 团队 Hooks 声明 diff --git a/skill-data/core/references/troubleshooting.md b/skill-data/core/references/troubleshooting.md index 844e838bf..e5a24ef3e 100644 --- a/skill-data/core/references/troubleshooting.md +++ b/skill-data/core/references/troubleshooting.md @@ -179,6 +179,8 @@ are preserved and remain untouched by automatic trust. Codex ownership uses the recorded event, position and complete entry. A moved entry is recovered only by a unique full-definition match. Legacy records recover only a unique event, matcher and command match; `timeout` and `additionalContextLimit` were not recorded. +Pre-#370 project Codex ownership is imported from the main checkout's +`.teamai/managed-hooks.json` before reconciliation or direct removal. Unrecorded or ambiguous legacy team-hook copies are preserved. Project hook paths follow `toolPaths`; Claude uses `settings.local.json` beside its configured settings file. A custom Codex path that Codex does not load is reported as `not loaded` by doctor. diff --git a/src/__tests__/codex-trust.test.ts b/src/__tests__/codex-trust.test.ts index 1dd7ff7c8..fab0c4d48 100644 --- a/src/__tests__/codex-trust.test.ts +++ b/src/__tests__/codex-trust.test.ts @@ -434,6 +434,14 @@ describe('reportCodexTrust', () => { expect(vi.mocked(log.warn).mock.calls[0]?.[0]).toMatch(/^Could not trust the teamai hooks in Codex \(hooks\/list: boom\)\./); }); + it.each([0, 3])('reports untrusted project configuration without assuming a hook path (%i hooks)', (hooks) => { + reportCodexTrust({ kind: 'project-untrusted', hooks, project: '/p' }, 'problems'); + expect(vi.mocked(log.warn).mock.calls[0]?.[0]).toBe( + "Codex marks /p as untrusted, so it does not load teamai's project hooks or MCP configuration. " + + 'teamai leaves that choice to you: trust the project in Codex to load its configuration.', + ); + }); + it('says a project the member marked untrusted was left so', () => { reportCodexTrust({ kind: 'project-untrusted', hooks: 0, project: '/p' }, 'problems'); expect(vi.mocked(log.warn).mock.calls[0]?.[0]).toMatch(/Codex marks \/p as untrusted/); diff --git a/src/__tests__/hooks-reconcile-scope.test.ts b/src/__tests__/hooks-reconcile-scope.test.ts index f4a6f1cca..96846b595 100644 --- a/src/__tests__/hooks-reconcile-scope.test.ts +++ b/src/__tests__/hooks-reconcile-scope.test.ts @@ -590,6 +590,49 @@ describe('reconcileTeamHooksForConfig — team hooks in the main checkout', () = expect(await fse.pathExists(path.join(project, '.claude', 'settings.local.json'))).toBe(false); }); + it.each([ + { target: 'main', removeAll: false }, + { target: 'worktree', removeAll: false }, + { target: 'main', removeAll: true }, + { target: 'worktree', removeAll: true }, + ])('uses pre-#370 ownership before reconciling main hooks %j', async ({ target, removeAll }) => { + await writeYaml(STOP_LINT); + const { main, worktree } = await mainWithWorktree(); + const file = path.join(main, '.codex', 'hooks.json'); + const legacyManifest = path.join(main, '.teamai', 'managed-hooks.json'); + const oldCommand = gated(main, 'npm run lint'); + const oldEntry = { hooks: [{ type: 'command', command: oldCommand, timeout: 30 }] }; + const member = { hooks: [{ type: 'command', command: 'npm run lint' }] }; + const cursorRecords = [{ id: 'other', event: 'Stop', command: 'echo cursor' }]; + await fse.outputJson(file, { hooks: { Stop: [member, oldEntry], PreToolUse: [oldEntry] } }); + await fse.outputJson(legacyManifest, { + codex: [{ id: 'lint', event: 'Stop', command: oldCommand }], cursor: cursorRecords, + }); + const root = target === 'main' ? main : worktree; + const cfg = { ...localConfig(), projectRoot: root }; + try { + await reconcileTeamHooksForConfig(teamConfig, cfg, { removeAll }); + + expect((await fse.readJson(file)).hooks.Stop).toEqual(removeAll ? [member] : [ + member, { hooks: [{ type: 'command', command: 'npm run lint' }] }, + ]); + expect((await fse.readJson(file)).hooks.PreToolUse).toEqual([oldEntry]); + expect(await fse.readJson(legacyManifest)).toEqual({ cursor: cursorRecords }); + const ownership = await fse.readJson(path.join(root, '.teamai', 'managed-main-checkout-hooks.json')); + expect((ownership.codex ?? []).map((record: { command: string }) => record.command)) + .toEqual(removeAll ? [] : ['npm run lint']); + if (!removeAll) { + const before = await fse.readFile(file, 'utf8'); + await reconcileTeamHooksForConfig(teamConfig, cfg); + expect(await fse.readFile(file, 'utf8')).toBe(before); + await reconcileTeamHooksForConfig(teamConfig, cfg, { removeAll: true }); + expect((await fse.readJson(file)).hooks.Stop).toEqual([member]); + } + } finally { + await fse.remove(worktree); + } + }); + it('replaces a recorded legacy copy in the main checkout\'s Codex file instead of duplicating it', async () => { await writeYaml(STOP_LINT); await fse.outputJson(path.join(project, '.codex', 'hooks.json'), { diff --git a/src/hooks.ts b/src/hooks.ts index e459af929..cc1820cf5 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -244,9 +244,8 @@ export function reportCodexTrust(trust: CodexTrust | undefined, mode: 'all' | 'p else log.debug(`Codex: trusted ${trust.hooks} teamai hook(s)`); return; case 'project-untrusted': - log.warn(`Codex marks ${trust.project} as untrusted, so it does not run the teamai hooks in ` - + `${path.join(trust.project, '.codex', 'hooks.json')}. teamai leaves that choice to you: ` - + 'trust the project in Codex to run them.'); + log.warn(`Codex marks ${trust.project} as untrusted, so it does not load teamai's project hooks or MCP configuration. ` + + 'teamai leaves that choice to you: trust the project in Codex to load its configuration.'); return; case 'disabled': case 'unavailable': @@ -1354,9 +1353,11 @@ export async function reconcileHooks( if (opts.manifestPath && manifest) { const records = codexRecords ?? manifestRecordsForTool(teamDefs, tool, !!opts.removeAll, opts.teamHookProjectRoot); const prev = manifest[tool] ?? []; + // Main-checkout team-only files belong to one project; their old gated + // records are consumed on migration. HOME retains the other projects. const retained = opts.teamHookProjectRoot ? prev.filter((r) => !isGatedForProject(r.command, opts.teamHookProjectRoot!)) - : prev.filter((r) => isProjectGatedCommand(r.command)); + : prev.filter((r) => !opts.teamOnly && isProjectGatedCommand(r.command)); const nextRecords = [...retained, ...records]; const sameAsPrev = JSON.stringify(prev) === JSON.stringify(nextRecords); const hadEntry = Object.prototype.hasOwnProperty.call(manifest, tool); @@ -1956,6 +1957,7 @@ export async function reconcileHooksToAllTools( if (mainFile && opts.mainCheckout && !opts.builtinsOnly) { await reconcileMainCheckoutTeamHooks(mainFile, tool, defs, { manifestPath: opts.mainCheckout.manifestPath, + legacyManifestPath: getManagedHooksPath('project', opts.mainCheckout.root), removeAll: opts.removeAll, }); } @@ -1975,11 +1977,24 @@ async function reconcileMainCheckoutTeamHooks( file: string, tool: string, teamDefs: HookDef[], - opts: { manifestPath: string; removeAll?: boolean }, + opts: { manifestPath: string; legacyManifestPath: string; removeAll?: boolean }, ): Promise { const wanted = opts.removeAll ? [] : teamDefsForTool(teamDefs, tool); if (wanted.length === 0 && !await pathExists(file)) return; + // Pre-#370 Codex hooks used this same file but recorded ownership beside the + // checkout. Keep that authority until reconciliation succeeds under the new + // manifest, including when the first upgraded command is `hooks remove`. + const legacy = tool === CODEX_TOOL_ID ? await readManifest(opts.legacyManifestPath) : null; + if (legacy?.[tool]?.length) { + const current = await readManifest(opts.manifestPath); + current[tool] = [...(current[tool] ?? []), ...legacy[tool]]; + await writeJson(expandHome(opts.manifestPath), current); + } await reconcileHooks(file, tool, teamDefs, { manifestPath: opts.manifestPath, removeAll: opts.removeAll, teamOnly: true }); + if (legacy?.[tool]?.length) { + delete legacy[tool]; + await writeJson(expandHome(opts.legacyManifestPath), legacy); + } } /** What a Codex trust pass for one scope works on. */ @@ -2166,8 +2181,8 @@ export async function sweepLegacyProjectHooks( const legacy = resolveLegacyProjectHookScope(localConfig); if (!legacy) return; // In the main checkout, Codex's legacy file is the one its team hooks now - // live in (MAIN_CHECKOUT_TEAM_HOOK_TOOLS); that pass removes the legacy - // built-ins from it, so the sweep must not empty it. + // live in (MAIN_CHECKOUT_TEAM_HOOK_TOOLS); that pass imports the legacy + // ownership and removes its built-ins, so the sweep must not empty it. const mainCheckout = await resolveMainCheckoutHooks(localConfig, toolPaths); const legacyToolPaths = Object.fromEntries(Object.entries(toolPaths).filter(([tool, paths]) => { const mainFile = mainCheckoutHookFile(mainCheckout, tool); From b928c3bc9ff65a50a3d559564fae916fb5daccc8 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 10:14:28 +0200 Subject: [PATCH 12/13] fix(hooks): limit main-checkout cleanup to owned entries (#955) In the main checkout, classify team-only entries by exact built-ins and manifest records instead of marker substrings, so member commands such as 'teamai pull --silent && ./notify' survive reconcile and uninstall. Sweep a coincident legacy Codex file when Codex is excluded from the main pass, and have uninstall reconcile that file once under both ownerships. --- src/__tests__/hooks-cmd.test.ts | 3 + src/__tests__/hooks-reconcile-scope.test.ts | 64 +++++++++++++++ src/__tests__/uninstall.test.ts | 40 ++++++++++ src/hooks-cmd.ts | 4 +- src/hooks.ts | 87 ++++++++++++++------- src/uninstall.ts | 30 +++++-- 6 files changed, 192 insertions(+), 36 deletions(-) diff --git a/src/__tests__/hooks-cmd.test.ts b/src/__tests__/hooks-cmd.test.ts index bbfc3bb98..b7678b37a 100644 --- a/src/__tests__/hooks-cmd.test.ts +++ b/src/__tests__/hooks-cmd.test.ts @@ -725,6 +725,8 @@ describe('hooksRemove', () => { localConfig: { ...mockLocalConfig, scope: 'project', projectRoot: '/path/to/project' }, teamConfig: mockTeamConfig, }); + const reconciledMainTools = new Set(['codex']); + mockedReconcile.mockResolvedValueOnce(reconciledMainTools); try { await hooksRemove({}); } finally { @@ -745,6 +747,7 @@ describe('hooksRemove', () => { expect(mockedSweep).toHaveBeenCalledWith( mockTeamConfig.toolPaths, expect.objectContaining({ scope: 'project', projectRoot: '/path/to/project' }), + reconciledMainTools, ); }); diff --git a/src/__tests__/hooks-reconcile-scope.test.ts b/src/__tests__/hooks-reconcile-scope.test.ts index 96846b595..ce6dff552 100644 --- a/src/__tests__/hooks-reconcile-scope.test.ts +++ b/src/__tests__/hooks-reconcile-scope.test.ts @@ -482,6 +482,42 @@ builtin: describe('reconcileTeamHooksForConfig — team hooks in the main checkout', () => { const STOP_LINT = 'hooks:\n - id: lint\n description: lint\n event: Stop\n command: npm run lint\n'; + it.each(['claude', 'codex'])('preserves unowned marker commands in %s main hooks from a worktree', async (tool) => { + await writeYaml(STOP_LINT); + const { main, worktree } = await mainWithWorktree(); + const file = path.join(main, tool === 'claude' ? '.claude/settings.local.json' : '.codex/hooks.json'); + const matcher = tool === 'claude' ? { matcher: '*' } : {}; + const raw = `teamai hook-dispatch session-start --tool ${tool}`; + const member = { ...matcher, hooks: [{ type: 'command', command: 'teamai pull --silent && ./notify' }] }; + const memberStart = [ + { ...matcher, hooks: [{ type: 'command', command: raw + ' && ./notify' }] }, + { ...matcher, hooks: [{ type: 'command', command: raw }, { type: 'command', command: './notify' }] }, + ]; + await fse.outputJson(file, { hooks: { + Stop: [member], + SessionStart: [{ ...matcher, hooks: [{ type: 'command', command: raw }] }, ...memberStart], + } }); + const cfg = { ...localConfig(), projectRoot: worktree }; + try { + await reconcileTeamHooksForConfig(teamConfig, cfg); + + expect((await fse.readJson(file)).hooks.Stop[0]).toEqual(member); + expect((await fse.readJson(file)).hooks.SessionStart).toEqual(memberStart); + const before = await fse.readFile(file, 'utf8'); + await reconcileTeamHooksForConfig(teamConfig, cfg); + expect(await fse.readFile(file, 'utf8')).toBe(before); + await writeYaml(STOP_LINT.replace('npm run lint', 'npm run lint:fix')); + await reconcileTeamHooksForConfig(teamConfig, cfg); + expect((await fse.readJson(file)).hooks.Stop).toHaveLength(2); + expect((await fse.readJson(file)).hooks.Stop[0]).toEqual(member); + await reconcileTeamHooksForConfig(teamConfig, cfg, { removeAll: true }); + expect((await fse.readJson(file)).hooks.Stop).toEqual([member]); + expect((await fse.readJson(file)).hooks.SessionStart).toEqual(memberStart); + } finally { + await fse.remove(worktree); + } + }); + it('uses project toolPaths for main hooks and userScope paths for HOME built-ins', async () => { await writeYaml(STOP_LINT); const custom = { toolPaths: { @@ -757,6 +793,34 @@ describe('reconcileTeamHooksForConfig — legacy projectRoot sweep', () => { expect(stale.hooks.stop ?? []).toHaveLength(0); }); + it.each(['enabled', 'disabled', 'selected'])('sweeps only legacy Codex ownership when excluded through %s agents', async (selection) => { + const legacyCommand = `[ "$PWD" = "${project}" ] && npm run lint`; + const legacy = { hooks: [{ type: 'command', command: legacyCommand }] }; + const builtin = { hooks: [{ type: 'command', command: 'teamai hook-dispatch session-start --tool codex' }] }; + const member = { hooks: [{ type: 'command', command: 'teamai pull --silent && ./notify' }] }; + const current = { hooks: [{ type: 'command', command: 'echo current' }] }; + await fse.outputJson(path.join(project, '.codex', 'hooks.json'), { + hooks: { SessionStart: [builtin], Stop: [legacy, member, current] }, + }); + await fse.outputJson(path.join(project, '.teamai', 'managed-hooks.json'), { + codex: [{ id: 'lint', event: 'Stop', command: legacyCommand }], + }); + const currentManifest = { codex: [{ id: 'current', event: 'Stop', command: 'echo current' }] }; + await fse.outputJson(path.join(project, '.teamai', 'managed-main-checkout-hooks.json'), currentManifest); + const cfg = { ...localConfig(), + ...(selection === 'enabled' ? { enabledAgents: ['claude'] } : {}), + ...(selection === 'disabled' ? { disabledAgents: ['codex'] } : {}), + }; + await reconcileTeamHooksForConfig(teamConfig, cfg, selection === 'selected' ? { filterAgents: ['claude'] } : {}); + + const after = await codexProject(); + expect(after.hooks.SessionStart).toEqual([]); + expect(after.hooks.Stop).toEqual([member, current]); + expect(await fse.readJson(path.join(project, '.teamai', 'managed-hooks.json'))).toEqual({}); + expect(await mainManifest()).toEqual(currentManifest); + expect(await fse.pathExists(path.join(home, '.codex', 'hooks.json'))).toBe(false); + }); + it('does not wipe the live hooks when projectRoot IS the home dir', async () => { // `teamai init .` run in ~ (dotfiles-style repo): the legacy location and // the live HOME target are the same file, so there is nothing to sweep. diff --git a/src/__tests__/uninstall.test.ts b/src/__tests__/uninstall.test.ts index e2e5d8f9e..851e93361 100644 --- a/src/__tests__/uninstall.test.ts +++ b/src/__tests__/uninstall.test.ts @@ -2021,6 +2021,46 @@ describe('uninstall', () => { ); }); + it('uninstalls a coincident Codex main file once using current and legacy ownership', async () => { + const projectRoot = path.join(tmpDir, 'proj-codex-ownership'); + const repoPath = path.join(projectRoot, '.teamai', 'team-repo'); + const homeDir = path.join(tmpDir, 'home'); + const file = path.join(projectRoot, '.codex', 'hooks.json'); + await fse.ensureDir(repoPath); + await fse.outputFile(path.join(projectRoot, '.teamai', 'config.yaml'), 'scope: project'); + await fse.outputJson(file, { hooks: { Stop: [ + { hooks: [{ type: 'command', command: 'npm run lint' }] }, + { hooks: [{ type: 'command', command: 'teamai pull --silent && ./notify' }] }, + ] } }); + await fse.outputJson(path.join(projectRoot, '.teamai', 'managed-hooks.json'), { + codex: [{ id: 'lint', event: 'Stop', command: 'npm run lint' }], + }); + vi.stubEnv('HOME', homeDir); + const localConfig = makeLocalConfig(projectRoot, repoPath, { scope: 'project', projectRoot }); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig: makeTeamConfig({ + toolPaths: { codex: { settings: '.codex/hooks.json' } }, + }) }); + // Exercise the real reconciler after discovery, rather than just its wiring. + const { reconcileHooks } = await vi.importActual('../hooks.js'); + mockReconcileHooks.mockImplementation(reconcileHooks); + + await uninstall({ force: true }); + + const realFile = await fse.realpath(file); + const calls = await Promise.all(mockReconcileHooks.mock.calls.map((c) => fse.realpath(c[0]).catch(() => c[0]))); + expect(calls.filter((c) => c === realFile)).toHaveLength(1); + expect((await fse.readJson(file)).hooks.Stop).toEqual([ + { hooks: [{ type: 'command', command: 'teamai pull --silent && ./notify' }] }, + ]); + expect(mockReconcileHooks).toHaveBeenCalledWith( + realFile, 'codex', [], + expect.objectContaining({ removeAll: true, teamOnly: true, + manifestPath: expect.stringContaining('managed-main-checkout-hooks.json'), + legacyManifestPath: expect.stringContaining('managed-hooks.json'), + }), + ); + }); + it('removes the separate team-hook files of live bare worktrees (#955)', async () => { const bare = path.join(tmpDir, 'bare.git'); const first = path.join(tmpDir, 'first'); diff --git a/src/hooks-cmd.ts b/src/hooks-cmd.ts index 6de37cf66..51ec81d1d 100644 --- a/src/hooks-cmd.ts +++ b/src/hooks-cmd.ts @@ -274,7 +274,7 @@ export async function hooksRemove(_options: GlobalOptions): Promise { // Removal must target the same paths injection used. A non-self project // scope injects into HOME, so resolving the project-scope paths here would // miss (and leave behind) every tool whose user-scope prefix differs. - await reconcileHooksToAllTools(scopedToolPaths(teamConfig, { ...localConfig, scope: hookScope }), baseDir, [], manifestPath, { + const reconciledMainTools = await reconcileHooksToAllTools(scopedToolPaths(teamConfig, { ...localConfig, scope: hookScope }), baseDir, [], manifestPath, { removeAll: true, scope: localConfig.scope, installedBaseDir: localConfig.scope === 'project' ? localConfig.projectRoot : undefined, @@ -303,7 +303,7 @@ export async function hooksRemove(_options: GlobalOptions): Promise { // differs from the primary target — never HOME (shared with user scope, and // the primary target itself when projectRoot IS the home dir), and never // re-running on the primary target in self mode. - await sweepLegacyProjectHooks(teamConfig.toolPaths, localConfig); + await sweepLegacyProjectHooks(teamConfig.toolPaths, localConfig, reconciledMainTools); // Pi has one shared user extension. `hooks remove` is an explicit global // hook-disable action even when invoked from a project; project uninstall diff --git a/src/hooks.ts b/src/hooks.ts index cc1820cf5..af66ef1aa 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -30,7 +30,7 @@ import { managedMcpWorkspaceId, resolveToolRootDir, } from './types.js'; -import { builtinHookDefs, applyBuiltinOverride, skipToolsWithoutShell, toolUsesCmdShell } from './builtin-hooks.js'; +import { builtinHookDefs, applyBuiltinOverride, getRawDispatchCommand, skipToolsWithoutShell, toolUsesCmdShell } from './builtin-hooks.js'; import type { BuiltinHookOverride } from './builtin-hooks.js'; import { resolveTeamHooks } from './resources/hooks.js'; import { getUserHome } from './utils/home.js'; @@ -303,6 +303,8 @@ export interface ReconcileHooksOptions { * builtin-only public API. */ manifestPath?: string; + /** Legacy ownership of the same project file, transferred after reconciliation. */ + legacyManifestPath?: string; /** §4.8 team override of built-in hooks (disabled / timeout). */ builtinOverride?: BuiltinHookOverride; /** Project root used to gate non-self project-scope team hooks. */ @@ -736,6 +738,18 @@ function isBuiltinClaudeEntry(entry: HookMatcher): boolean { return TEAMAI_COMMAND_MARKERS.some((marker) => cmd.includes(marker)); } +/** Match standard built-ins exactly when cleaning a project team-only file. */ +function isExactBuiltinEntry(event: string, tool: string, entry: HookMatcher | CodexHookMatcher): boolean { + if (entry.hooks?.length !== 1 || entry.hooks[0].type !== 'command') return false; + const command = entry.hooks[0].command; + return builtinHookDefs(tool).some((def) => { + if (def.event !== event || (entry.matcher ?? '*') !== (def.matcher ?? '*')) return false; + const dispatch = /\bhook-dispatch ([a-z-]+)/.exec(def.command)?.[1]; + return command === def.command + || (dispatch !== undefined && command === getRawDispatchCommand(dispatch, tool, def.matcher)); + }); +} + /** True if a settings entry is a teamai team (B) hook. */ function isTeamClaudeEntry(entry: HookMatcher): boolean { return (entry.description ?? '').startsWith(TEAMAI_CUSTOM_HOOK_PREFIX); @@ -759,6 +773,7 @@ async function reconcileClaudeFormat( teamActive: boolean, desiredTeamCommands: Set, priorTeamCommands: Set, + priorRecords: ManagedHookRecord[], ): Promise { // Built-in management never removes team hooks; team hooks are reconciled only // when a team pass is active (manifest present). This keeps the builtin-only @@ -768,7 +783,11 @@ async function reconcileClaudeFormat( const desiredTeamIds = new Set( teamDefs.filter((d) => !d.tools || d.tools.includes(tool)).map((d) => d.key), ); - const isManaged = (e: HookMatcher): boolean => { + const isManaged = (event: string, e: HookMatcher): boolean => { + if (opts.teamOnly) { + return isExactBuiltinEntry(event, tool, e) || (e.hooks?.length === 1 && priorRecords.some((r) => + r.event === event && (r.matcher ?? '*') === (e.matcher ?? '*') && r.command === e.hooks[0].command)); + } if (isBuiltinClaudeEntry(e) || (!!opts.removeAll && isAgentClaudeEntry(e))) return true; if (!teamActive || !isTeamClaudeEntry(e)) return false; // Project-scope hooks share HOME with other projects. Only remove entries @@ -810,7 +829,7 @@ async function reconcileClaudeFormat( for (const event of events) { const existing = settings.hooks[event] ?? []; - const untouched = existing.filter((e) => !isManaged(e)); + const untouched = existing.filter((e) => !isManaged(event, e)); const desiredEntries = defs.filter((d) => d.event === event).map(toClaudeEntry); const newArr = [...untouched, ...desiredEntries]; if (JSON.stringify(existing) !== JSON.stringify(newArr)) { @@ -980,7 +999,9 @@ async function reconcileCodexFormat( const isManaged = (event: string, index: number, entries: CodexHookMatcher[]): boolean => { const entry = entries[index]; const cmd = entry.hooks?.[0]?.command ?? ''; - return TEAMAI_COMMAND_MARKERS.some((marker) => cmd.includes(marker)) + return (opts.teamOnly + ? isExactBuiltinEntry(event, tool, entry) + : TEAMAI_COMMAND_MARKERS.some((marker) => cmd.includes(marker))) || priorRecords.some((record) => tool === CODEX_TOOL_ID ? ownsCodexEntry(record, event, index, entries) : record.command === cmd); @@ -1322,6 +1343,14 @@ export async function reconcileHooks( ): Promise { const teamActive = !!opts.manifestPath; const manifest = opts.manifestPath ? await readManifest(opts.manifestPath) : null; + // Pre-#370 Codex hooks used this same file. Persist their authority in the + // new manifest before touching the file; retire the old records only on success. + const legacy = opts.teamOnly && tool === CODEX_TOOL_ID && opts.legacyManifestPath && manifest + ? await readManifest(opts.legacyManifestPath) : null; + if (legacy?.[tool]?.length && manifest && opts.manifestPath) { + manifest[tool] = [...(manifest[tool] ?? []), ...legacy[tool]]; + await writeJson(expandHome(opts.manifestPath), manifest); + } const allPriorRecords = manifest?.[tool] ?? []; const priorRecords = opts.teamHookProjectRoot ? allPriorRecords.filter((r) => isGatedForProject(r.command, opts.teamHookProjectRoot!)) @@ -1346,7 +1375,7 @@ export async function reconcileHooks( // In a shared HOME settings file, only remove team entries belonging to // this project. User-scope installs retain the historical marker sweep. teamHookProjectRoot: opts.teamHookProjectRoot, - }, teamActive, desiredTeamCommands, priorTeamCommands); + }, teamActive, desiredTeamCommands, priorTeamCommands, priorRecords); } // Update the manifest's team-hook index for this tool (when manifest is active). @@ -1371,6 +1400,10 @@ export async function reconcileHooks( await writeJson(expandHome(opts.manifestPath), manifest); } } + if (legacy?.[tool]?.length && opts.legacyManifestPath) { + delete legacy[tool]; + await writeJson(expandHome(opts.legacyManifestPath), legacy); + } } // ─── Back-compatible public API (built-in A only) ─────────── @@ -1777,7 +1810,7 @@ export async function reconcileHooksToAllTools( teamDefs: HookDef[], manifestPath: string, opts: { removeAll?: boolean; builtinOverride?: BuiltinHookOverride; filterAgents?: string[]; settingsOnly?: boolean; installedBaseDir?: string; teamHookProjectRoot?: string; scope?: Scope; builtinsOnly?: BuiltinsOnly; mainCheckout?: MainCheckoutHooks | null } = {}, -): Promise { +): Promise> { // Without the manifest, reconcileHooks manages the built-in entries only. const teamManifestPath = opts.builtinsOnly ? undefined : manifestPath; const defs = opts.builtinsOnly ? [] : teamDefs; @@ -1810,6 +1843,7 @@ export async function reconcileHooksToAllTools( // One physical file can carry only one dispatch identity; this is the documented // limit of sharing a project scope, not a bug this pass can fix. const claimedSettingsFiles = new Set(); + const reconciledMainTools = new Set(); for (const [tool, paths] of Object.entries(toolPaths)) { if (opts.filterAgents && !opts.filterAgents.includes(tool)) continue; if (skipped.has(tool)) continue; @@ -1960,11 +1994,13 @@ export async function reconcileHooksToAllTools( legacyManifestPath: getManagedHooksPath('project', opts.mainCheckout.root), removeAll: opts.removeAll, }); + reconciledMainTools.add(tool); } } catch (e) { log.warn(`Failed to reconcile hooks for ${tool}: ${(e as Error).message}`); } } + return reconciledMainTools; } /** @@ -1981,20 +2017,7 @@ async function reconcileMainCheckoutTeamHooks( ): Promise { const wanted = opts.removeAll ? [] : teamDefsForTool(teamDefs, tool); if (wanted.length === 0 && !await pathExists(file)) return; - // Pre-#370 Codex hooks used this same file but recorded ownership beside the - // checkout. Keep that authority until reconciliation succeeds under the new - // manifest, including when the first upgraded command is `hooks remove`. - const legacy = tool === CODEX_TOOL_ID ? await readManifest(opts.legacyManifestPath) : null; - if (legacy?.[tool]?.length) { - const current = await readManifest(opts.manifestPath); - current[tool] = [...(current[tool] ?? []), ...legacy[tool]]; - await writeJson(expandHome(opts.manifestPath), current); - } - await reconcileHooks(file, tool, teamDefs, { manifestPath: opts.manifestPath, removeAll: opts.removeAll, teamOnly: true }); - if (legacy?.[tool]?.length) { - delete legacy[tool]; - await writeJson(expandHome(opts.legacyManifestPath), legacy); - } + await reconcileHooks(file, tool, teamDefs, { ...opts, teamOnly: true }); } /** What a Codex trust pass for one scope works on. */ @@ -2177,17 +2200,25 @@ export async function readCodexHookTrustForScope( export async function sweepLegacyProjectHooks( toolPaths: Record, localConfig: LocalConfig, + reconciledMainTools: ReadonlySet = new Set(), ): Promise { const legacy = resolveLegacyProjectHookScope(localConfig); if (!legacy) return; - // In the main checkout, Codex's legacy file is the one its team hooks now - // live in (MAIN_CHECKOUT_TEAM_HOOK_TOOLS); that pass imports the legacy - // ownership and removes its built-ins, so the sweep must not empty it. + // A coincident main file is handled by the main pass only when that tool + // actually ran. Excluded tools still need legacy cleanup, without claiming + // current-generation or member entries in the same file. const mainCheckout = await resolveMainCheckoutHooks(localConfig, toolPaths); - const legacyToolPaths = Object.fromEntries(Object.entries(toolPaths).filter(([tool, paths]) => { + const legacyToolPaths: typeof toolPaths = {}; + for (const [tool, paths] of Object.entries(toolPaths)) { const mainFile = mainCheckoutHookFile(mainCheckout, tool); - return !(mainFile && paths.settings && path.join(canonicalProjectRoot(legacy.baseDir), paths.settings) === mainFile); - })); + if (mainFile && paths.settings && path.join(canonicalProjectRoot(legacy.baseDir), paths.settings) === mainFile) { + if (!reconciledMainTools.has(tool) && await pathExists(mainFile)) { + await reconcileHooks(mainFile, tool, [], { removeAll: true, teamOnly: true, manifestPath: legacy.manifestPath }); + } + } else { + legacyToolPaths[tool] = paths; + } + } await reconcileHooksToAllTools(legacyToolPaths, legacy.baseDir, [], legacy.manifestPath, { removeAll: true, settingsOnly: true, @@ -2292,7 +2323,7 @@ export async function reconcileTeamHooksForConfig( } return resolved.ok ? { ok: true, defs: teamDefs } : { ok: false, builtins: builtinsOnly ?? 'with-overrides' }; } - await reconcileHooksToAllTools(hookToolPaths, baseDir, teamDefs, manifestPath, { + const reconciledMainTools = await reconcileHooksToAllTools(hookToolPaths, baseDir, teamDefs, manifestPath, { removeAll: opts.removeAll, builtinOverride: builtin, filterAgents, @@ -2333,7 +2364,7 @@ export async function reconcileTeamHooksForConfig( } } if (builtinsOnly) return { ok: false, builtins: builtinsOnly }; - await sweepLegacyProjectHooks(teamConfig.toolPaths, localConfig); + await sweepLegacyProjectHooks(teamConfig.toolPaths, localConfig, reconciledMainTools); return { ok: true, defs: teamDefs }; } diff --git a/src/uninstall.ts b/src/uninstall.ts index bc5243bba..143723e62 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -1,3 +1,4 @@ +import { realpath } from 'node:fs/promises'; import path from 'node:path'; import { autoDetectInit, saveLocalConfig, saveLocalConfigForScope, UnreadableProjectConfigError } from './config.js'; import { reconcileHooks, hasTeamaiHooks, mainCheckoutHookFile, resolveMainCheckoutHooks } from './hooks.js'; @@ -86,7 +87,7 @@ interface UninstallOptions extends GlobalOptions { interface RemovalPlan { /** Tool settings files that contain teamai hooks (each with the manifest that * recorded its team hooks — HOME/user or a legacy /project one). */ - hookFiles: Array<{ path: string; tool: string; manifestPath: string }>; + hookFiles: Array<{ path: string; tool: string; manifestPath: string; teamOnly?: boolean; legacyManifestPath?: string }>; /** OpenClaw-style hook dirs (/./hooks) holding teamai HOOK.md+handler.ts. */ openclawHookDirs: Array<{ hooksDir: string; tool: string }>; /** OpenCode teamai plugin files (.opencode/plugin/teamai-*.ts) to delete. */ @@ -145,7 +146,7 @@ interface SkillDirEntry { } interface ToolResources { - hookFiles: Array<{ path: string; tool: string; manifestPath: string }>; + hookFiles: Array<{ path: string; tool: string; manifestPath: string; teamOnly?: boolean; legacyManifestPath?: string }>; openclawHookDirs: Array<{ hooksDir: string; tool: string }>; opencodeHookScopes: Array<{ baseDir: string; scope: Scope }>; ompHookFile: string | null; @@ -298,6 +299,8 @@ interface HookTarget { baseDir: string; manifestPath: string; fileFor?: (tool: string) => string | null; + teamOnly?: boolean; + legacyManifestPath?: string; } async function discoverToolResources( @@ -413,15 +416,26 @@ async function discoverToolResources( // from the same scope decision (`hookSettingsPath`), not from `toolPath` — // except for the legacy copy, written into by a CLI that knew // nothing about a member's relocated root, so it sits at the team path. - for (const { baseDir: hookBaseDir, manifestPath, fileFor } of hookTargets) { + // Main-checkout files are canonical; a legacy target may name one through a symlink. + const canonical = (file: string) => realpath(file).catch(() => path.resolve(file)); + const mainFiles = new Set(await Promise.all(hookTargets.flatMap((target) => { + const file = target.teamOnly ? target.fileFor?.(tool) : null; + return file ? [canonical(file)] : []; + }))); + for (const { baseDir: hookBaseDir, manifestPath, fileFor, teamOnly, legacyManifestPath } of hookTargets) { const settingsRel = path.resolve(hookBaseDir) === path.resolve(getUserHome()) ? (hookSettingsPath ?? toolPath.settings) : toolPath.settings; const settingsPath = fileFor ? fileFor(tool) : path.join(hookBaseDir, settingsRel); + // Prefer main-file ownership when a legacy target names the same file. + if (!teamOnly && settingsPath && mainFiles.has(await canonical(settingsPath))) continue; if (settingsPath && await pathExists(settingsPath) && (await hasTeamaiHooks(settingsPath, tool, manifestPath) + || (legacyManifestPath && await hasTeamaiHooks(settingsPath, tool, legacyManifestPath)) || isEmptyHooksResidue(await readJson>(settingsPath)))) { - res.hookFiles.push({ path: settingsPath, tool, manifestPath }); + res.hookFiles.push({ path: settingsPath, tool, manifestPath, + ...(teamOnly ? { teamOnly, legacyManifestPath } : {}), + }); } } } else { @@ -597,6 +611,8 @@ async function buildRemovalPlan( hookTargets.push({ baseDir: target.root, manifestPath: target.manifestPath, + teamOnly: true, + legacyManifestPath: getManagedHooksPath('project', target.root), fileFor: (tool) => mainCheckoutHookFile(target, tool), }); } @@ -993,9 +1009,11 @@ async function executeRemoval(plan: RemovalPlan): Promise { // or a legacy /project copy), so team hooks are stripped at the // location that owns them. File-based adapters apply their own scope rules // below; in particular, project uninstall never owns Pi's global extension. - for (const { path: settingsPath, tool, manifestPath } of plan.hookFiles) { + for (const { path: settingsPath, tool, manifestPath, teamOnly, legacyManifestPath } of plan.hookFiles) { try { - await reconcileHooks(settingsPath, tool, [], { removeAll: true, manifestPath }); + await reconcileHooks(settingsPath, tool, [], { removeAll: true, manifestPath, + ...(teamOnly ? { teamOnly, legacyManifestPath } : {}), + }); } catch (e) { log.warn(`Failed to remove hooks from ${settingsPath}: ${(e as Error).message}`); } From 8042fbb6547501675c42fe6df8f9b4c31856443c Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 10:44:15 +0200 Subject: [PATCH 13/13] fix(hooks): match Claude main-checkout ownership by hook id (#955) --- src/__tests__/hooks-reconcile-scope.test.ts | 22 +++++++++++++++++++++ src/hooks.ts | 3 ++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/__tests__/hooks-reconcile-scope.test.ts b/src/__tests__/hooks-reconcile-scope.test.ts index ce6dff552..013bdd478 100644 --- a/src/__tests__/hooks-reconcile-scope.test.ts +++ b/src/__tests__/hooks-reconcile-scope.test.ts @@ -518,6 +518,28 @@ describe('reconcileTeamHooksForConfig — team hooks in the main checkout', () = } }); + it('preserves a claude member hook sharing the team command with a different definition', async () => { + await writeYaml(STOP_LINT); + const { main, worktree } = await mainWithWorktree(); + const file = path.join(main, '.claude/settings.local.json'); + const cfg = { ...localConfig(), projectRoot: worktree }; + try { + await reconcileTeamHooksForConfig(teamConfig, cfg); + const generated = (await fse.readJson(file)).hooks.Stop[0]; + const member = { matcher: '*', hooks: [{ type: 'command', command: generated.hooks[0].command, timeout: 30 }], description: 'mine' }; + await fse.outputJson(file, { hooks: { Stop: [member, generated] } }); + + await reconcileTeamHooksForConfig(teamConfig, cfg); + await reconcileTeamHooksForConfig(teamConfig, cfg); + + expect((await fse.readJson(file)).hooks.Stop).toEqual([member, generated]); + await reconcileTeamHooksForConfig(teamConfig, cfg, { removeAll: true }); + expect((await fse.readJson(file)).hooks.Stop).toEqual([member]); + } finally { + await fse.remove(worktree); + } + }); + it('uses project toolPaths for main hooks and userScope paths for HOME built-ins', async () => { await writeYaml(STOP_LINT); const custom = { toolPaths: { diff --git a/src/hooks.ts b/src/hooks.ts index af66ef1aa..447e57443 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -786,7 +786,8 @@ async function reconcileClaudeFormat( const isManaged = (event: string, e: HookMatcher): boolean => { if (opts.teamOnly) { return isExactBuiltinEntry(event, tool, e) || (e.hooks?.length === 1 && priorRecords.some((r) => - r.event === event && (r.matcher ?? '*') === (e.matcher ?? '*') && r.command === e.hooks[0].command)); + r.event === event && (r.matcher ?? '*') === (e.matcher ?? '*') && r.command === e.hooks[0].command + && teamHookIdOf(e.description) === r.id)); } if (isBuiltinClaudeEntry(e) || (!!opts.removeAll && isAgentClaudeEntry(e))) return true; if (!teamActive || !isTeamClaudeEntry(e)) return false;