diff --git a/docs/designs/data-directory-layout.md b/docs/designs/data-directory-layout.md index cf611d149..11d9c5744 100644 --- a/docs/designs/data-directory-layout.md +++ b/docs/designs/data-directory-layout.md @@ -495,7 +495,14 @@ 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) +│ 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 ├── 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 0cb32188c..3eceb581a 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -722,7 +722,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. 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)). @@ -1022,13 +1022,13 @@ 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. > 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. The `codex` entry also needs `mcpProject: .codex/config.toml` for the project's team MCP servers. -> 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 @@ -1319,7 +1319,7 @@ Where each tool's servers land: | omp | `~/.omp/agent/mcp.json` | `/.omp/mcp.json` | | pi | `~/.pi/agent/mcp.json` | `/.pi/mcp.json` | -Codex reads `/.codex/config.toml` only in a trusted project. Trust the project when Codex asks, or add a `[projects."
"]` table with `trust_level = "trusted"` to `~/.codex/config.toml`; trusting the main checkout covers every worktree of the repository. `teamai doctor` reports an untrusted project whose file holds team servers. +Codex reads `/.codex/config.toml` only in a trusted project. After writing team MCP servers, `teamai pull` trusts the main checkout automatically, unless `codexTrustEnabled: false` is set or the project was explicitly marked untrusted. If automatic trust is disabled or fails, add a `[projects."
"]` table with `trust_level = "trusted"` to `~/.codex/config.toml`; trusting the main checkout covers every worktree of the repository. `teamai doctor` reports an untrusted project whose file holds team servers. CodeBuddy Code's [MCP documentation](https://www.codebuddy.ai/docs/cli/mcp) @@ -2043,13 +2043,17 @@ 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. Existing Claude/Codex main-checkout hook files also count as installed targets when the HOME and current worktree tool roots are missing. Injection and pull update those team hooks and restore HOME built-ins; removal clears the managed main-checkout hooks without recreating HOME roots. + +`hooks inject` still attempts to trust the written Codex hooks if Git-hook installation fails. It preserves the installation error and does not report overall injection success. + +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. -> **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, 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. The post-checkout preparation creates that directory and runs pull before the first session for selected Codex tools. Hosts that skip checkout hooks must finish that preparation before starting Codex. If only SessionStart creates the directory, the team hooks load from the next Codex session; 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 @@ -2372,7 +2376,7 @@ Three tools do not read a rules directory, so a per-file check cannot speak for `MCP servers delivered to ` compares each server the team's `mcp.yaml` resolves for that tool against the entry in the tool's own config, and names any the reconcile skipped with its reason. The comparison is the entry, not the name: reconciliation leaves an entry teamai does not own alone, so a server of your own under a team name holds the key while the team's definition never arrives, and a stale copy is just as undelivered. Both are reported as `not the team's definition`, and only `teamai pull --force` replaces an entry teamai did not write. An unresolved `${VAR}` is reported here with the variable's name, which is otherwise said once during a pull and never again. A declared secret with no value is not a failure: doctor prints it as a note (`notes` in `--json`) with the command that sets it, and the exit code stays as it would be without it; a note also says when an entry kept for it may hold an old value, and when a key is declared as a secret and also set in `env.yaml`. An `mcp.yaml` that does not parse is not a team without MCP: it is reported as `Team MCP servers can be read` with the parse error, since it injects nothing into any tool and every run after the first is silent about it. Team hooks and team model profiles that cannot be resolved (a file that does not parse, a name defined twice in one file, or one name in two active namespaces) fail `Team hooks can be resolved` and `Team model profiles can be resolved` with the reason pull logs once; `teamai status` points here when it counts them as 0. `Env variables injected in shell profile` no longer stops at finding the marker comment: it checks that `env/env.yaml` parses and declares its variables under the `variables:` key (a plain `KEY: value` mapping parses as none, while an explicit `variables: []` is a configuration with nothing to deliver and fails nothing), that each one reached `env.sh` with the value `env.yaml` declares, or your value for this team (one set with `--from-env` is not written there) — a key left over from an older value exports it to every shell and MCP server until the next pull, and the comparison reads `env.sh` back through the generator's own inverse, so a multiline value quoted across several lines is matched rather than called stale — and that this scope's injected block (the one sourcing its own `env.sh`, since a profile can also carry another scope's) would actually load it — an unquoted Windows path degrades to something a POSIX shell cannot read, so `source` never runs and nothing says so. `No stale env blocks left behind` is a separate check: which file `pull` prefers has changed over time (Windows Git Bash's login shell reads `.bash_profile`/`.bash_login`/`.profile`, never `.bashrc`), and a pull only ever adds a block, never migrates an old one away, so a dead block from an earlier install or platform change can sit in another candidate file indefinitely. It names every such file (checking `.zshrc`, `.bashrc`, `.bash_profile`, `.bash_login` and `.profile`, current and legacy spellings alike) and points at `teamai uninstall` to remove them — separately from delivery, so a working env block never reads as broken just because an old one is still lying around. -`Codex trusts this project, so it loads its team MCP servers` is built in project scope while the project's `.codex/config.toml` holds a server this worktree's `managed-mcp.json` records for Codex: Codex loads that file only in a trusted project, and skips an untrusted one without saying so. It reads the `projects` table of the Codex user config (`~/.codex/config.toml`, or the one under `toolRoots.codex`) as Codex does, taking the first `projects.""` entry that sets a `trust_level` for the checkout, then for its main checkout, each by real path (`/private/tmp/...`, not `/tmp/...`). It fails, naming the file and its servers, until that entry sets `trust_level = "trusted"`, and a pull reports the failure in its closing checks too. Trust the project when Codex asks, or add the entry for the main checkout yourself, which covers every worktree. doctor only reads that file. +`Codex trusts this project, so it loads its team MCP servers` is built in project scope while the project's `.codex/config.toml` holds a server this worktree's `managed-mcp.json` records for Codex: Codex loads that file only in a trusted project, and skips an untrusted one without saying so. It reads the `projects` table of the Codex user config (`~/.codex/config.toml`, or the one under `toolRoots.codex`) as Codex does, taking the first `projects.""` entry that sets a `trust_level` for the checkout, then for its main checkout, each by real path (`/private/tmp/...`, not `/tmp/...`). It fails, naming the file and its servers, until that entry sets `trust_level = "trusted"`, and a pull reports the failure in its closing checks too. If the check still fails after pull attempts automatic trust, change project trust in Codex or add the main-checkout entry yourself, which covers every worktree. doctor only reads that file. `Contributed learnings are published` fails while `teamai contribute` has notes queued that could not be pushed. A manual `teamai pull` does not repeat it at the end when the pull has already said it: the pull tries to publish the queue and reports the outcome itself, with the push error that made it fail — more than this check can tell you. If the pull never got that far, because the team repo failed to refresh, the check is printed as usual. @@ -2393,7 +2397,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: @@ -2723,6 +2727,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 @@ -2730,7 +2735,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. @@ -2964,7 +2969,7 @@ teamai init --repo https://github.com/yourorg/yourrepo --scope user --role : 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 共用一份。路径遵循项目的 `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-正交的维度))。 @@ -923,13 +923,13 @@ 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`。 > 团队 `teamai.yaml` 中的 `toolPaths` 会整体替换内置默认值。设置了它的团队应为每个 Codex 系条目加上 `userScope.claudemd: .codex/AGENTS.md`(`.codex-internal/…`、`.tcodex/…`),用于 user scope 的 rule 和区块,并去掉其 `rules` 路径,因为 Codex 从不读取该目录。顶层的 `claudemd` 会把区块重新写进项目 `AGENTS.md`,所以不要设置。在项目中,hook 只需要该条目的 `settings` 路径,它安装在那里。`codex` 条目还需要 `mcpProject: .codex/config.toml`,项目的团队 MCP server 才会写入。 -> 从把 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 划分 @@ -1177,7 +1177,7 @@ namespace 文件;只有当根文件未定义、而多个 namespace 文件都 | omp | `~/.omp/agent/mcp.json` | `/.omp/mcp.json` | | pi | `~/.pi/agent/mcp.json` | `/.pi/mcp.json` | -Codex 只在受信任的项目中读取 `/.codex/config.toml`。请在 Codex 询问时信任该项目,或在 `~/.codex/config.toml` 中加入 `[projects."<主 checkout 的真实路径>"]` 表并设置 `trust_level = "trusted"`;信任主 checkout 即覆盖该仓库的所有 worktree。项目未受信任、而其文件含有团队 server 时,`teamai doctor` 会报告。 +Codex 只在受信任的项目中读取 `/.codex/config.toml`。写入团队 MCP servers 后,`teamai pull` 会自动信任主 checkout,除非设置了 `codexTrustEnabled: false`,或该项目已被明确标记为不信任。自动信任被禁用或失败时,可在 `~/.codex/config.toml` 中加入 `[projects."<主 checkout 的真实路径>"]` 表并设置 `trust_level = "trusted"`;信任主 checkout 即覆盖该仓库的所有 worktree。项目未受信任、而其文件含有团队 server 时,`teamai doctor` 会报告。 CodeBuddy Code 的 [MCP 文档](https://www.codebuddy.cn/docs/cli/mcp) @@ -1888,13 +1888,17 @@ 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 和当前 worktree 的工具根目录缺失时,主 checkout 中现存的 Claude/Codex hook 文件也视为已安装的目标。inject 和 pull 会更新这些团队 hooks 并恢复 HOME 中的内置 hooks;remove 会清理主 checkout 中的托管 hooks,而不重建 HOME 根目录。 + +Git hook 安装失败时,`hooks inject` 仍会尝试信任已经写入的 Codex hooks。它保留安装错误,不显示整体注入成功。 + +非-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`,才会带上这个判断。 -> **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 不受影响,即使命令与团队 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`。post-checkout 准备步骤会为所选的 Codex 工具创建该目录,并在第一个会话之前完成 pull。跳过 checkout hooks 的宿主必须在启动 Codex 前完成准备。如果仅由 SessionStart 创建该目录,团队 hooks 从下一个 Codex 会话起加载;内置 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 声明 @@ -2215,7 +2219,7 @@ teamai remove rules --force # 跳过确认,用于脚本和 CI `MCP servers delivered to ` 将团队 `mcp.yaml` 为该工具解析出的每个 server 与该工具自己配置文件中的条目逐一比对,并列出 reconcile 跳过的 server 及原因。比对的是条目内容而非名字:reconcile 不会覆盖不属于 teamai 的条目,因此你自己写的同名 server 会占住这个名字,团队的定义从未真正送达;过期的旧副本同样等于没送达。两者都报告为 `not the team's definition`,而覆盖非 teamai 写入的条目只有 `teamai pull --force` 能做到。未解析的 `${VAR}` 会在这里连同变量名一起报告——否则它只在 pull 时出现一次,之后再无提示。没有值的已声明密钥不算失败:doctor 把它作为备注打印(`--json` 中的 `notes`),并附上设置它的命令,退出码与没有它时相同;备注还会说明为它保留的条目可能含有旧值,以及某个 key 既声明为密钥、又在 `env.yaml` 中设置的情况。无法解析的 `mcp.yaml` 并不等于团队没有 MCP:它会作为 `Team MCP servers can be read` 连同解析错误一起报告,因为这种文件不会向任何工具注入内容,而且除第一次之外的每次运行都对此保持沉默。无法解析的团队 hooks 与团队模型配置(文件无法解析、同一文件内重复的名字,或两个活动 namespace 中的同名条目)会让 `Team hooks can be resolved` 与 `Team model profiles can be resolved` 失败,并给出 pull 只记录一次的原因;`teamai status` 把它们计为 0 时会指向这里。`Env variables injected in shell profile` 不再只查标记注释:它会检查 `env/env.yaml` 能否解析、以及是否在 `variables:` 键下声明了变量(写成普通的 `KEY: value` 映射等于没有声明;而显式写成 `variables: []` 属于没有内容要下发的配置,不会判为失败)、每个变量是否以 `env.yaml` 声明的值(或你为该团队设置的值;用 `--from-env` 设置的不会写入)写进了 `env.sh`(残留的旧值会一直被导出到每个 shell 和 MCP server,直到下次 pull;比对时会用生成器自身的逆运算读回 `env.sh`,因此跨多行引用的多行值能够正确匹配,而不会被误判为过期),以及本作用域注入的代码块(即 source 本作用域 `env.sh` 的那一块,因为同一个 profile 里还可能有其他作用域的代码块)是否真的能加载它——未加引号的 Windows 路径在 POSIX shell 中会被转义破坏,`source` 从不执行,而且没有任何提示。`No stale env blocks left behind` 是独立的一项检查:pull 优先选用哪个文件会随时间变化(Windows 上 Git Bash 的登录 shell 读取的是 `.bash_profile`/`.bash_login`/`.profile`,从不读取 `.bashrc`),而 pull 只会新增代码块,从不迁移旧的,因此早期安装或平台变化留下的失效代码块可能一直留在另一个候选文件里。它会列出每一个这样的文件(检查 `.zshrc`、`.bashrc`、`.bash_profile`、`.bash_login` 和 `.profile`,新旧写法都算),并指向 `teamai uninstall` 来清除它们——这与投递检查分开进行,因此不会因为还留着一个旧副本,就让一个正常工作的 env 代码块被判成故障。 -`Codex trusts this project, so it loads its team MCP servers` 在 project scope 下、项目的 `.codex/config.toml` 含有本 worktree 的 `managed-mcp.json` 为 Codex 记录的 server 时生成:Codex 只在受信任的项目中加载该文件,对未受信任的项目则静默跳过。它按 Codex 的方式读取 Codex 用户配置(`~/.codex/config.toml`,或 `toolRoots.codex` 下的那份)中的 `projects` 表:先取当前 checkout 的、设置了 `trust_level` 的 `projects.""` 条目,再取其主 checkout 的,均按真实路径(`/private/tmp/...` 而非 `/tmp/...`)。在该条目设置 `trust_level = "trusted"` 之前,它会失败,并指出文件及其中的 server;pull 结束时的检查也会报告这一失败。请在 Codex 询问时信任该项目,或自行为主 checkout 加上该条目,这样即覆盖所有 worktree。doctor 只读取该文件。 +`Codex trusts this project, so it loads its team MCP servers` 在 project scope 下、项目的 `.codex/config.toml` 含有本 worktree 的 `managed-mcp.json` 为 Codex 记录的 server 时生成:Codex 只在受信任的项目中加载该文件,对未受信任的项目则静默跳过。它按 Codex 的方式读取 Codex 用户配置(`~/.codex/config.toml`,或 `toolRoots.codex` 下的那份)中的 `projects` 表:先取当前 checkout 的、设置了 `trust_level` 的 `projects.""` 条目,再取其主 checkout 的,均按真实路径(`/private/tmp/...` 而非 `/tmp/...`)。在该条目设置 `trust_level = "trusted"` 之前,它会失败,并指出文件及其中的 server;pull 结束时的检查也会报告这一失败。pull 尝试自动信任后,如果该检查仍失败,请在 Codex 中修改项目信任,或自行为主 checkout 加上该条目,这样即覆盖所有 worktree。doctor 只读取该文件。 `Contributed learnings are published` 会在 `teamai contribute` 写下、但尚未推送成功的笔记仍在队列中时失败。当本次 pull 已经说过时,手动 `teamai pull` 结束时不会再重复它:pull 会尝试发布队列并自行报告结果,还会带上导致失败的推送错误——这是该检查本身给不出的信息。如果 pull 因为团队仓库刷新失败而根本没走到那一步,该检查会照常打印。 @@ -2236,7 +2240,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 中执行,可通过两层控制: @@ -2534,6 +2538,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 @@ -2541,7 +2546,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。 @@ -2755,7 +2760,7 @@ teamai init --repo https://github.com/yourorg/yourrepo --scope user --role { 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__/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 new file mode 100644 index 000000000..fab0c4d48 --- /dev/null +++ b/src/__tests__/codex-trust.test.ts @@ -0,0 +1,728 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import path from 'node:path'; +import os from 'node:os'; +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'; + +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 () => { + 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(); + 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 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-'))); + 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.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'); + 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); + 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('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', key: `${file}:stop:0:0` }] }); + 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()); + + 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('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 }); + + 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.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/); + }); +}); + +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('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()); + 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('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'); + 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); + + 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 worktree = `${project}-wt`; + 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')); + 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 { + await fse.remove(worktree); + } + }); + + it('shares the trusted fingerprint when alternating project checkouts', async () => { + const worktree = `${project}-wt`; + 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 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 { + await fse.remove(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..b612697c4 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,60 @@ 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('✔')]); + }); + + 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'); + }); + + 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'); }); - await doctor({}); + 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); + }); - 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__/e2e/hooks-project-isolation-issue373.test.ts b/src/__tests__/e2e/hooks-project-isolation-issue373.test.ts index 480f6db3e..12c101be0 100644 --- a/src/__tests__/e2e/hooks-project-isolation-issue373.test.ts +++ b/src/__tests__/e2e/hooks-project-isolation-issue373.test.ts @@ -4,13 +4,14 @@ 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'); -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 +29,16 @@ 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 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'), + path.join(project, '.codex', 'hooks.json'), + ]; beforeAll(() => { if (!fs.existsSync(CLI)) throw new Error('Run npm run build before e2e tests'); @@ -35,6 +46,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 +59,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 +81,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 +104,71 @@ 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([expectedCommand(file, 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([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]); + 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(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/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 0b7f2a899..88df8b0e0 100644 --- a/src/__tests__/e2e/project-scoped-delivery.test.ts +++ b/src/__tests__/e2e/project-scoped-delivery.test.ts @@ -71,7 +71,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)) { @@ -86,6 +92,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 }); @@ -241,7 +248,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')); }); @@ -280,6 +287,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 @@ -325,6 +336,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..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 ────────────── // @@ -11,9 +12,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 +67,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 +113,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 +154,7 @@ describe('issue #85 remaining scope-isolation gaps (e2e)', () => { 'username: ci-proj', 'updatePolicy: auto', 'scope: project', + 'codexTrustEnabled: false', ].join('\n'), ); }, 60_000); @@ -163,44 +171,67 @@ 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([`${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()) { + 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__/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..6aca81e9b --- /dev/null +++ b/src/__tests__/helpers/fake-codex.ts @@ -0,0 +1,120 @@ +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) => { + 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; + 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 ? (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); + } + 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.pid = process.pid; + 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 { + pid?: number; + 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; 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__/hooks-cmd.test.ts b/src/__tests__/hooks-cmd.test.ts index bf5b59917..e18532116 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,22 +181,37 @@ 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(); }); + 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({}); @@ -196,6 +222,19 @@ describe('hooksInject', () => { } }); + it.each([false, true])('trusts written Codex hooks while preserving git-hook installation failure, silent=%s', async (silent) => { + const failure = new Error('Could not install the teamai git hook in /repo: EACCES'); + mockedReconcileForConfig.mockRejectedValue(failure); + mockedTrustCodex.mockResolvedValue({ kind: 'trusted', hooks: 8 }); + + await expect(hooksInject({ silent })).rejects.toBe(failure); + + expect(mockedTrustCodex).toHaveBeenCalledWith(mockTeamConfig, mockLocalConfig, { force: true }); + 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'); + }); + it('fails, without the success line, when the git hook cannot be installed', async () => { mockedReconcileForConfig.mockRejectedValue(new Error('Could not install the teamai git hook in /repo: EACCES')); await expect(hooksInject({})).rejects.toThrow('Could not install the teamai git hook'); @@ -694,7 +733,7 @@ describe('hooksRemove', () => { expect.any(String), [], expect.stringContaining('managed-hooks.json'), - { removeAll: true, scope: 'user', installedBaseDir: undefined }, + { removeAll: true, scope: 'user', installedBaseDir: undefined, teamHookProjectRoot: undefined, mainCheckout: null }, ); expect(mockedLog.success).toHaveBeenCalledWith(expect.stringContaining('Hooks removed')); }); @@ -705,6 +744,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 { @@ -718,13 +759,14 @@ describe('hooksRemove', () => { '/home/testuser', [], expect.any(String), - { removeAll: true, scope: 'project', installedBaseDir: '/path/to/project' }, + { 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'); expect(mockedSweep).toHaveBeenCalledWith( mockTeamConfig.toolPaths, expect.objectContaining({ scope: 'project', projectRoot: '/path/to/project' }), + reconciledMainTools, ); }); @@ -755,7 +797,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', teamHookProjectRoot: undefined, mainCheckout: null }, ); }); diff --git a/src/__tests__/hooks-reconcile-scope.test.ts b/src/__tests__/hooks-reconcile-scope.test.ts index 71e4046c1..6dbedeb6c 100644 --- a/src/__tests__/hooks-reconcile-scope.test.ts +++ b/src/__tests__/hooks-reconcile-scope.test.ts @@ -4,11 +4,18 @@ 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 { reconcileTeamHooksForConfig } from '../hooks.js'; +import { resolveAnchors, listWorktrees } from '../utils/git.js'; +import { CLAUDE_HOOK_OTHER_HOST_SKIP, reconcileTeamHooksForConfig } from '../hooks.js'; import * as gitHook from '../git-hook.js'; import type { LocalConfig, TeamaiConfig } from '../types.js'; @@ -54,8 +61,22 @@ 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 () => { + 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-')); @@ -105,22 +126,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(`${CLAUDE_HOOK_OTHER_HOST_SKIP}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 () => { @@ -151,16 +183,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([`${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([`${CLAUDE_HOOK_OTHER_HOST_SKIP}echo project-b`]); } finally { await fse.remove(projectB); await fse.remove(repoB); @@ -192,11 +230,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 () => { @@ -226,20 +267,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); @@ -257,14 +297,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 @@ -273,7 +313,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(); @@ -281,8 +321,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'); @@ -296,7 +336,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 () => { @@ -346,9 +386,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 () => { @@ -368,7 +408,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); @@ -454,6 +494,339 @@ 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'; + + 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('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: { + 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-')); + 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)}`); + await fse.ensureDir(worktree); + vi.mocked(resolveAnchors).mockImplementation(async (cwd) => ({ workspaceRoot: cwd ?? main, projectAnchor: main })); + vi.mocked(listWorktrees).mockResolvedValue([main, 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 { + await fse.remove(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 { + await fse.remove(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.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'), { + hooks: { + SessionStart: [{ hooks: [{ type: 'command', command: 'teamai hook-dispatch session-start --tool codex' }] }], + 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()); + + 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.each(['claude', 'codex'])('refreshes existing %s main hooks and restores HOME built-ins when both tool roots are missing', async (tool) => { + await writeYaml(STOP_LINT); + const { main, worktree } = await mainWithWorktree(); + const cfg = { ...localConfig(), projectRoot: worktree }; + const file = path.join(main, tool === 'claude' ? '.claude/settings.local.json' : '.codex/hooks.json'); + const homeFile = path.join(home, tool === 'claude' ? '.claude/settings.json' : '.codex/hooks.json'); + try { + await reconcileTeamHooksForConfig(teamConfig, cfg); + const member = { hooks: [{ type: 'command', command: 'echo member' }] }; + const json = await fse.readJson(file); + json.hooks.Stop.push(member); + await fse.writeJson(file, json); + await fse.remove(path.join(home, `.${tool}`)); + await writeYaml(STOP_LINT.replace('npm run lint', 'npm run lint:fix')); + + await reconcileTeamHooksForConfig(teamConfig, cfg); + + const entries = (await fse.readJson(file)).hooks.Stop; + expect(entries).toHaveLength(2); + expect(entries).toContainEqual(member); + expect(entries.some((entry: { hooks: Array<{ command: string }> }) => entry.hooks[0].command.includes('npm run lint:fix'))).toBe(true); + expect((await fse.readJson(homeFile)).hooks.SessionStart).toHaveLength(1); + expect(await fse.pathExists(path.join(worktree, `.${tool}`))).toBe(false); + const before = await fse.readFile(file, 'utf8'); + await reconcileTeamHooksForConfig(teamConfig, cfg); + expect(await fse.readFile(file, 'utf8')).toBe(before); + } finally { + await fse.remove(worktree); + } + }); + + it('does not install absent tools without an existing main-checkout hook file', async () => { + await writeYaml(STOP_LINT); + await fse.remove(path.join(home, '.claude')); + await fse.remove(path.join(home, '.codex')); + + await reconcileTeamHooksForConfig(teamConfig, localConfig()); + + for (const root of [home, project]) { + expect(await fse.pathExists(path.join(root, '.claude'))).toBe(false); + expect(await fse.pathExists(path.join(root, '.codex'))).toBe(false); + } + }); + + 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()); + + 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: { @@ -505,6 +878,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. @@ -578,10 +979,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 0289378e1..2f5c8079a 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); @@ -752,6 +754,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 new file mode 100644 index 000000000..ce0b3ce2e --- /dev/null +++ b/src/__tests__/pull-codex-trust.test.ts @@ -0,0 +1,208 @@ +/** + * 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(), + resolveAnchors: vi.fn().mockResolvedValue(null), + listWorktrees: vi.fn().mockResolvedValue([]), + 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 { resolveAnchors, listWorktrees } from '../utils/git.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 { 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 () => { + 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'); + 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'); + + localConfig = { + repo: { localPath: repoPath, remote: 'owner/repo' }, + username: 'tester', + scope: 'project', + projectRoot: project, + primaryRole: 'dev', + additionalRoles: [], + } as unknown as LocalConfig; + 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' }); + }); + it('pull from a worktree without .codex trusts the main keys before SessionStart', async () => { + const worktree = path.join(tempDir, '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); + 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/__tests__/uninstall.test.ts b/src/__tests__/uninstall.test.ts index 6dc49ce6e..81d6af348 100644 --- a/src/__tests__/uninstall.test.ts +++ b/src/__tests__/uninstall.test.ts @@ -2342,6 +2342,104 @@ 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') }), + ); + }); + + 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'); + 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/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..ecb6375af --- /dev/null +++ b/src/codex-trust.ts @@ -0,0 +1,272 @@ +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 } + /** The app-server failed, timed out, or did not load a requested hook. */ + | { 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; + /** 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; +} + +/** 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(); + }, + }; + 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 }; + +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, key: string): string { + return `${canonical(file)}\0${key}\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 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, 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, 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', + 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, h.key))); + 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.key), h.trustStatus])); + return { + kind: 'listed', + notTrusted: req.hooks + .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/doctor.ts b/src/doctor.ts index 47b927af4..a3fcf8ab0 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, @@ -124,7 +124,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[]; } @@ -323,7 +323,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).' + : '.'), }; } @@ -349,21 +351,32 @@ 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 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: (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.', + }], + notes: [], + }; } /** @@ -636,26 +649,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). @@ -665,7 +674,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..ec45b1862 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'; @@ -10,6 +10,7 @@ import { COPILOT_TOOL_ID, getManagedHooksPath, isAgentExcluded, + isSelfMode, resolveHookScope, resolveToolBaseDir, scopedToolPaths, @@ -98,30 +99,22 @@ 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, - }); + let reconciled: Awaited>; + try { + reconciled = await reconcileTeamHooksForConfig(teamConfig, localConfig, { + auto: false, + silent: options.silent, + }); + } finally { + // Git-hook installation can fail after the Codex hooks were written. + const codexTrust = await trustCodexForScope(teamConfig, localConfig, { force: true }); + if (!options.silent) reportCodexTrust(codexTrust, 'all'); + } // The reason is already reported; the installed team hooks were left as they were. if (!reconciled.ok) { process.exitCode = 1; - return; - } - let codexTrustGated = false; - if (await hasInstalledCodexTrustGatedTool(teamConfig.toolPaths, baseDir)) { - codexTrustGated = 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()); - } } + if (!options.silent && reconciled.ok) log.success('Hooks injected into all AI tool settings'); } /** @@ -280,10 +273,15 @@ 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, + 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, teamConfig.toolPaths), }); const copilotPaths = scopedToolPaths(teamConfig, localConfig)[COPILOT_TOOL_ID]; @@ -304,7 +302,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 e4c7c6d73..ac6caf17b 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -1,7 +1,9 @@ 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 } 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 +19,25 @@ import { scopedToolPaths, toolInstallRoot, } from './types.js'; -import type { HookDef, TeamaiConfig, LocalConfig, Scope } from './types.js'; -import { isSelfMode } from './types.js'; -import { builtinHookDefs, applyBuiltinOverride, skipToolsWithoutShell, toolUsesCmdShell } from './builtin-hooks.js'; +import type { HookDef, TeamaiConfig, LocalConfig, ManagedMcpManifest, Scope } from './types.js'; +import { + CODEX_TOOL_ID, + DEFAULT_CODEX_ROOT, + getDataHome, + isSelfMode, + managedMcpManifestKey, + managedMcpManifestPath, + managedMcpWorkspaceId, + resolveToolRootDir, +} from './types.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'; 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 +203,65 @@ 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 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': + 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 +277,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_TOOLS). + */ +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 ─────────────────────────── @@ -248,10 +303,18 @@ 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. */ 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. Team entries are owned only + * when the manifest records them; a matching command alone is not ownership. + */ + teamOnly?: boolean; } /** One injected team hook recorded in the manifest. */ @@ -260,6 +323,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. */ @@ -377,6 +443,113 @@ 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_TOOLS) 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_TOOLS = ['claude', 'codex'] as const; + +/** Where a non-self project scope keeps Claude/Codex team hooks. */ +export interface MainCheckoutHooks { + /** The main checkout (realpath), or the current workspace when the anchor is bare. */ + root: string; + /** 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>; +} + +/** + * 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, + toolPaths: Record, +): Promise { + if (localConfig.scope !== 'project' || !localConfig.projectRoot || isSelfMode(localConfig)) return null; + const { root, worktreeScoped } = await mainCheckoutOf(localConfig.projectRoot); + if (root === canonicalProjectRoot(getUserHome())) return null; + 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. A bare anchor has no main checkout, + * so each actual workspace supplies its own hook files and ownership. + */ +async function mainCheckoutOf(projectRoot: string): Promise<{ root: string; worktreeScoped: boolean }> { + const anchors = await pathExists(projectRoot) ? await resolveAnchors(projectRoot) : null; + 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 { + return mainCheckout?.files[tool] ?? 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, @@ -565,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); @@ -588,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 @@ -597,7 +783,12 @@ 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 + && teamHookIdOf(e.description) === r.id)); + } 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 @@ -633,13 +824,13 @@ 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))]; 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)) { @@ -674,7 +865,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 +945,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]; @@ -799,28 +990,47 @@ 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 (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); }; - 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))]; 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; @@ -833,6 +1043,23 @@ async function reconcileCodexFormat( } else { log.debug(`teamai hooks already up-to-date in ${hooksPath}`); } + return records; +} + +/** 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; + // 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 ───────────── @@ -908,7 +1135,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))]; @@ -1117,21 +1344,30 @@ 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!)) : 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)); 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 { @@ -1140,16 +1376,18 @@ 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). 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] ?? []; + // 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); @@ -1163,6 +1401,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) ─────────── @@ -1568,8 +1810,8 @@ 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 } = {}, -): Promise { + 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; const defs = opts.builtinsOnly ? [] : teamDefs; @@ -1602,6 +1844,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; @@ -1714,45 +1957,227 @@ 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 mainFile = mainCheckoutHookFile(opts.mainCheckout, tool); + const installed = await pathExists(toolRoot) || await pathExists(installedRoot) + || (!opts.removeAll && mainFile !== null && await pathExists(mainFile)); + // 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; - await reconcileHooks(settingsPath, tool, defs, { - manifestPath: teamManifestPath, - removeAll: opts.removeAll, - builtinOverride: opts.builtinOverride, - teamHookProjectRoot: opts.teamHookProjectRoot, - }); + 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: 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, + 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; +} + +/** + * Reconcile the team hooks of one tool's main-checkout file + * (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. + */ +async function reconcileMainCheckoutTeamHooks( + file: string, + tool: string, + teamDefs: HookDef[], + opts: { manifestPath: string; legacyManifestPath: string; removeAll?: boolean }, +): Promise { + const wanted = opts.removeAll ? [] : teamDefsForTool(teamDefs, tool); + if (wanted.length === 0 && !await pathExists(file)) return; + await reconcileHooks(file, tool, teamDefs, { ...opts, 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; + /** 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. */ + inputs: string[]; + /** The Codex project MCP servers teamai wrote for this checkout. */ + mcpRecords: unknown[]; +} + +/** 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 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` }]; + })); +} + +/** + * 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)).root; + const mainCheckout = await resolveMainCheckoutHooks(localConfig, teamConfig.toolPaths); + 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[0] = { 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: anchor ?? 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'); } /** - * True if a trust-gated Codex tool (the public `codex`) is both configured with - * a settings path and actually installed on disk under baseDir. + * 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; } - return false; + 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 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, cwd: localConfig.projectRoot ?? targets.cwd }); } /** @@ -1777,10 +2202,26 @@ export async function hasInstalledCodexTrustGatedTool( export async function sweepLegacyProjectHooks( toolPaths: Record, localConfig: LocalConfig, + reconciledMainTools: ReadonlySet = new Set(), ): Promise { const legacy = resolveLegacyProjectHookScope(localConfig); if (!legacy) return; - await reconcileHooksToAllTools(toolPaths, legacy.baseDir, [], legacy.manifestPath, { + // 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: typeof toolPaths = {}; + for (const [tool, paths] of Object.entries(toolPaths)) { + const mainFile = mainCheckoutHookFile(mainCheckout, tool); + 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, }); @@ -1813,6 +2254,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 +2303,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. @@ -1874,7 +2326,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, @@ -1884,6 +2336,7 @@ export async function reconcileTeamHooksForConfig( installedBaseDir: localConfig.scope === 'project' ? (localConfig.projectRoot ?? baseDir) : undefined, scope: localConfig.scope, builtinsOnly, + mainCheckout: await resolveMainCheckoutHooks(localConfig, teamConfig.toolPaths), }); const copilotExcluded = disabled?.includes(COPILOT_TOOL_ID) ?? false; @@ -1913,7 +2366,7 @@ export async function reconcileTeamHooksForConfig( ); } } - if (!builtinsOnly) await sweepLegacyProjectHooks(teamConfig.toolPaths, localConfig); + if (!builtinsOnly) await sweepLegacyProjectHooks(teamConfig.toolPaths, localConfig, reconciledMainTools); // Last, so a repository whose hooks cannot be written still gets the agent // hooks above; the error then reaches the caller. if (!opts.removeAll) await installProjectGitHook(localConfig); diff --git a/src/init.ts b/src/init.ts index 784eac3c7..1e404b58f 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'; @@ -798,6 +798,7 @@ async function reconcileHooksForInit( return; } if (!reconciled.ok) log.warn(describeUnappliedTeamHooks(reconciled)); + reportCodexTrust(await trustCodexForScope(teamConfig, localConfig, { filterAgents, force: true }), 'all'); // The hooks install the extensions and plugins that add team instructions // for Pi, OMP and Hermes; name what keeps a tool from getting them (#945). try { diff --git a/src/pull.ts b/src/pull.ts index 8aa9e7af0..115d9adf1 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -2428,6 +2428,12 @@ export async function pull( // hooks. User-scope MCP remains isolated in project mode. startupErrors.push(...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. @@ -2762,6 +2768,28 @@ async function reconcileHooksAllScopes( return errors; } +/** Trust Codex hooks and projects after both hooks and MCP reconciliation. */ +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}`); + } + } +} + /** * 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 diff --git a/src/types.ts b/src/types.ts index a7ede167d..9fab51b69 100644 --- a/src/types.ts +++ b/src/types.ts @@ -617,6 +617,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(), /** @@ -827,6 +831,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(), /** * OpenCode configs whose `instructions` entry for teamai's context file * teamai added, as `{ config, entry }`, so uninstall removes the entry even diff --git a/src/uninstall.ts b/src/uninstall.ts index f937fc352..0e8329946 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -1,6 +1,7 @@ +import { realpath } from 'node:fs/promises'; 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, @@ -70,6 +71,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, @@ -86,7 +88,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. */ @@ -159,7 +161,7 @@ interface OpencodeInstruction { } 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; @@ -313,6 +315,19 @@ 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; + teamOnly?: boolean; + legacyManifestPath?: string; +} + async function discoverToolResources( tool: string, toolPath: TeamaiConfig['toolPaths'][string], @@ -322,7 +337,7 @@ async function discoverToolResources( teamSkillNames: Set, teamRuleNames: Set, teamAgentNames: Set, - hookTargets: Array<{ baseDir: string; manifestPath: string }>, + hookTargets: HookTarget[], standaloneHookManifestPath: string, scope: Scope, /** @@ -430,21 +445,32 @@ 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) { + // 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 = path.join(hookBaseDir, settingsRel); + 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; // Other installs use Codex's user hooks as their instruction channel. - if (!globalAdapters && CODEX_TOOL_IDS.some((id) => id === tool) + if (settingsPath && !globalAdapters && CODEX_TOOL_IDS.some((id) => id === tool) && path.resolve(hookBaseDir) === path.resolve(getUserHome())) { if (await pathExists(settingsPath) && await hasTeamaiHooks(settingsPath, tool, manifestPath)) res.keptGlobal.push(settingsPath); continue; } - if (await pathExists(settingsPath) + 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 { @@ -617,9 +643,30 @@ 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, 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: target.root, + manifestPath: target.manifestPath, + teamOnly: true, + legacyManifestPath: getManagedHooksPath('project', target.root), + fileFor: (tool) => mainCheckoutHookFile(target, 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 @@ -1104,9 +1151,11 @@ async function executeRemoval(plan: RemovalPlan): Promise/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}`); } diff --git a/src/utils/git.ts b/src/utils/git.ts index 353125960..37a4b4359 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; } 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; }