diff --git a/docs/usage-guide.md b/docs/usage-guide.md index daaf1418e..4ed60c0aa 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1367,6 +1367,8 @@ teamai hooks remove # Remove The inject and remove commands only touch tools you actually have installed (i.e. whose `~/./` root directory already exists). They never create root directories for tools listed in `toolPaths` but not installed. +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`. + > **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. ### Team Hooks Declaration diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 768e6e33c..9e6d30f8e 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1327,6 +1327,8 @@ teamai hooks remove # 移除 inject 和 remove 只会操作你实际已安装的工具(即 `~/./` 根目录已存在的工具)。对于 `toolPaths` 中已配置但未安装的工具,命令不会为其凭空创建根目录。 +在 Windows 上,经由 bash 执行的内置 hook 派发命令(如 Claude、Codex、Cursor、Copilot CLI)会以绝对路径引用 Git Bash——先查标准安装位置,再回退到 `HKLM\SOFTWARE\GitForWindows` 注册表——从而避免解析到 WSL 的 `bash.exe`;若找不到 Git Bash,则退回裸 `bash`。 + > **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]` 来自动信任 —— 信任操作交由你手动完成。 ### 团队 Hooks 声明 diff --git a/src/__tests__/copilot-adapter.test.ts b/src/__tests__/copilot-adapter.test.ts index 02fb742d0..f0f41955a 100644 --- a/src/__tests__/copilot-adapter.test.ts +++ b/src/__tests__/copilot-adapter.test.ts @@ -9,6 +9,7 @@ import { reconcileHooks, reconcileTeamHooksForConfig, } from '../hooks.js'; +import { _resetShellCache } from '../builtin-hooks.js'; import { RulesHandler } from '../resources/rules.js'; import { SkillsHandler } from '../resources/skills.js'; import { teamRuleToCopilotInstructions } from '../resources/copilot-instructions.js'; @@ -345,4 +346,47 @@ describe('GitHub Copilot adapter', () => { await handler.pullItem(missingTeamRule, teamConfig, localConfig('user')); expect(await fse.pathExists(destination)).toBe(false); }); + + describe('Windows Git Bash launcher rendering', () => { + let fakeGitRoot: string; + + beforeEach(async () => { + // A host may carry a real Git for Windows under ProgramFiles; point every + // candidate at the fake tree so the resolved launcher is deterministic. + fakeGitRoot = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-copilot-gitbash-')); + const bin = path.join(fakeGitRoot, 'Programs', 'Git', 'bin'); + await fse.ensureDir(bin); + await fse.writeFile(path.join(bin, 'bash.exe'), ''); + vi.stubEnv('ProgramFiles', path.join(fakeGitRoot, 'missing-pf')); + vi.stubEnv('ProgramFiles(x86)', path.join(fakeGitRoot, 'missing-pf86')); + vi.stubEnv('LOCALAPPDATA', fakeGitRoot); + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + _resetShellCache(); + }); + + afterEach(async () => { + _resetShellCache(); + vi.restoreAllMocks(); + await fse.remove(fakeGitRoot); + }); + + it('renders the powershell field with the call operator when Git Bash is found', async () => { + await reconcileTeamHooksForConfig(teamConfig, localConfig('user')); + const hookPath = path.join(copilotHome, 'hooks', COPILOT_HOOK_FILE); + const first = await fse.readFile(hookPath, 'utf8'); + await reconcileTeamHooksForConfig(teamConfig, localConfig('user')); + const second = await fse.readFile(hookPath, 'utf8'); + expect(second).toBe(first); + + const parsed = JSON.parse(second) as { + hooks: Record>; + }; + const entry = parsed.hooks.SessionStart[0]; + const bashExe = path.join(fakeGitRoot, 'Programs', 'Git', 'bin', 'bash.exe').split(path.sep).join('/'); + const dispatch = 'teamai hook-dispatch session-start --tool copilot'; + expect(entry.bash).toBe(`"${bashExe}" -lc "${dispatch} 2>/dev/null" || true`); + expect(entry.powershell).toBe(`& "${bashExe}" -lc "${dispatch} 2>/dev/null"; exit 0`); + expect(entry.command).toBe(entry.bash); + }); + }); }); diff --git a/src/__tests__/hooks-golden.test.ts b/src/__tests__/hooks-golden.test.ts index 9221516bc..346203d15 100644 --- a/src/__tests__/hooks-golden.test.ts +++ b/src/__tests__/hooks-golden.test.ts @@ -13,14 +13,21 @@ import { injectHooks } from '../hooks.js'; // fixtures/hooks/.json were captured from the pre-refactor injector. // Any refactor of the injection engine MUST keep these byte-identical so that // already-installed machines see a zero-diff reconcile after a CLI upgrade. +// +// Windows: the dispatch shell resolves a bare `bash` to the WSL launcher, so +// the injector names Git Bash by absolute path instead — machine-specific, and +// the Linux-captured fixtures cannot match it. Only the dispatch-command +// renderers (claude, claude-internal, cursor) skip there; the wrapper +// renderers (codebuddy, workbuddy) stay machine-independent and keep coverage. const fixturesDir = path.resolve(__dirname, 'fixtures', 'hooks'); -const cases: Array<[string, string]> = [ - ['claude', 'settings.json'], - ['claude-internal', 'settings.json'], - ['codebuddy', 'settings.json'], - ['cursor', 'hooks.json'], - ['workbuddy', 'settings.json'], +// true = the command carries the dispatch shell prefix (`getDispatchCommand`). +const cases: Array<[string, string, boolean]> = [ + ['claude', 'settings.json', true], + ['claude-internal', 'settings.json', true], + ['codebuddy', 'settings.json', false], + ['cursor', 'hooks.json', true], + ['workbuddy', 'settings.json', false], ]; describe('hooks golden — built-in output is byte-identical to the captured baseline', () => { @@ -32,8 +39,8 @@ describe('hooks golden — built-in output is byte-identical to the captured bas await fse.remove(tmp); }); - for (const [tool, file] of cases) { - it(`${tool} output matches golden fixture`, async () => { + for (const [tool, file, usesDispatchCommand] of cases) { + it.skipIf(process.platform === 'win32' && usesDispatchCommand)(`${tool} output matches golden fixture`, async () => { const p = path.join(tmp, tool, file); await injectHooks(p, tool); const got = await fse.readFile(p, 'utf-8'); diff --git a/src/__tests__/hooks-windows-bash.test.ts b/src/__tests__/hooks-windows-bash.test.ts new file mode 100644 index 000000000..9adf58115 --- /dev/null +++ b/src/__tests__/hooks-windows-bash.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import path from 'node:path'; +import fs from 'node:fs'; +import os from 'node:os'; + +vi.mock('../utils/logger.js', () => ({ + log: { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +// A developer machine may have a real Git for Windows InstallPath in HKLM, so +// the default registry locator would succeed and defeat the negative case. +// Replace execFileSync at the module boundary (builtin-hooks imports it as a +// named binding, which a bare spyOn on the default export cannot intercept); +// the registry-success path is instead covered by injecting a fake locator. +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal(); + const boom = (() => { throw new Error('registry unreadable'); }) as unknown as typeof actual.execFileSync; + return { ...actual, default: actual, execFileSync: boom }; +}); + +import { getDispatchCommand, findGitBashWindows, _resetShellCache } from '../builtin-hooks.js'; + +// Windows resolves a bare `bash` to System32's WSL launcher before any PATH +// entry, and the WSL side has a different $HOME and no npm-global teamai — the +// hook then dies inside `2>/dev/null || true` with nothing logged. The +// injector must instead name Git Bash with an absolute path. + +function makeFakeGit(root: string, relative: string[] = ['Programs', 'Git']): string { + const bin = path.join(root, ...relative, 'bin'); + fs.mkdirSync(bin, { recursive: true }); + fs.writeFileSync(path.join(bin, 'bash.exe'), ''); + return path.join(bin, 'bash.exe'); +} + +const noRegistry = () => { + throw new Error('unreachable in tests'); +}; + +describe('findGitBashWindows', () => { + it('finds Git under the LOCALAPPDATA candidate', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-gitbash-')); + const exe = makeFakeGit(root); + expect(findGitBashWindows({ LOCALAPPDATA: root }, path.join(root, 'nohome'), noRegistry)).toBe(exe); + }); + + it('finds Git under the machine-wide ProgramFiles candidate', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-gitbash-')); + const exe = makeFakeGit(path.join(root, 'PF'), ['Git']); + expect(findGitBashWindows({ ProgramFiles: path.join(root, 'PF') }, path.join(root, 'nohome'), noRegistry)).toBe(exe); + }); + + it('falls back to the registry InstallPath when no candidate exists', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-gitbash-')); + const exe = makeFakeGit(root, ['Git']); + const installDir = path.dirname(path.dirname(exe)); + const read = () => installDir; + expect( + findGitBashWindows( + { ProgramFiles: path.join(root, 'm1'), 'ProgramFiles(x86)': path.join(root, 'm2'), LOCALAPPDATA: path.join(root, 'm3') }, + path.join(root, 'nohome'), + read, + ), + ).toBe(exe); + }); + + it('returns null when neither candidates nor registry find Git', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-gitbash-empty-')); + expect( + findGitBashWindows({ ProgramFiles: root, 'ProgramFiles(x86)': root, LOCALAPPDATA: root }, root, () => null), + ).toBeNull(); + }); +}); + +describe('getDispatchCommand shell resolution', () => { + afterEach(() => { + _resetShellCache(); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it('POSIX keeps the bare bash form (golden-fixture compatibility)', () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); + expect(getDispatchCommand('stop', 'claude')).toBe( + 'bash -lc "teamai hook-dispatch stop --tool claude 2>/dev/null" || true', + ); + }); + + it('Windows names Git Bash by absolute, quoted, forward-slash path', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-gitbash-')); + const exe = makeFakeGit(root); + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + vi.stubEnv('HOME', root); + vi.stubEnv('ProgramFiles', path.join(root, 'missing-pf')); + vi.stubEnv('ProgramFiles(x86)', path.join(root, 'missing-pf86')); + vi.stubEnv('LOCALAPPDATA', root); + const cmd = getDispatchCommand('stop', 'qoder'); + expect(cmd).toBe( + '"' + exe.split(path.sep).join('/') + '" -lc "teamai hook-dispatch stop --tool qoder 2>/dev/null" || true', + ); + }); + + it('Windows keeps bare bash when Git Bash cannot be found', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-gitbash-empty-')); + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + vi.stubEnv('HOME', root); + vi.stubEnv('ProgramFiles', root); + vi.stubEnv('ProgramFiles(x86)', root); + vi.stubEnv('LOCALAPPDATA', root); + // No execFileSync stub: the registry probe really runs, and no machine + // records InstallPath under a freshly-created temp dir, so the locator + // exhausts every source and the command must degrade to bare `bash`. + expect(getDispatchCommand('stop', 'claude')).toBe( + 'bash -lc "teamai hook-dispatch stop --tool claude 2>/dev/null" || true', + ); + }); +}); diff --git a/src/builtin-hooks.ts b/src/builtin-hooks.ts index 27c214901..f01672c01 100644 --- a/src/builtin-hooks.ts +++ b/src/builtin-hooks.ts @@ -1,5 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; +import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { TEAMAI_HOOK_DESCRIPTION_PREFIX } from './types.js'; import type { HookDef } from './types.js'; @@ -60,6 +61,7 @@ export function hasShell(): boolean { /** Reset the cached shell results. Test-only. */ export function _resetShellCache(): void { _hasShellCache = undefined; + _winBashLauncherCache = undefined; resetBundledRuntimeCache(); } @@ -159,11 +161,86 @@ export function skipToolsWithoutShell(tools: string[]): Set { return skipped; } +/** + * Read the machine-wide InstallPath the Git for Windows installer records + * in HKLM. Exported so tests stub it at the module boundary instead of + * shelling out to a real reg.exe. Returns null on any failure — an + * unreadable registry just means "no extra candidate". + */ +export function queryGitInstallPath(): string | null { + try { + const out = execFileSync( + 'reg.exe', + ['query', 'HKLM\\SOFTWARE\\GitForWindows', '/v', 'InstallPath'], + { timeout: 5000, windowsHide: true, encoding: 'utf8' }, + ); + const match = out.match(/InstallPath\s+REG_SZ\s+(.+)/); + return match?.[1]?.trim() || null; + } catch { + return null; + } +} + +/** + * Locate Git Bash on Windows. CreateProcess resolves a bare `bash` to + * System32's WSL launcher before any PATH entry (the rationale already + * documented for ZCode in hooks.ts), and the ZCode cmd fallback is not + * available to rendered shell-string commands, so on Windows the + * interpreter has to be an absolute path. Standard install locations + * first; the HKLM `GitForWindows` key covers custom InstallPath. + * Returns the exe path, or null when Git is not found. + */ +export function findGitBashWindows( + env: NodeJS.ProcessEnv = process.env, + home: string = getUserHome(), + readInstallPath: () => string | null = queryGitInstallPath, +): string | null { + const candidates: string[] = []; + if (env.ProgramFiles) candidates.push(path.join(env.ProgramFiles, 'Git', 'bin', 'bash.exe')); + if (env['ProgramFiles(x86)']) candidates.push(path.join(env['ProgramFiles(x86)'], 'Git', 'bin', 'bash.exe')); + if (env.LOCALAPPDATA) candidates.push(path.join(env.LOCALAPPDATA, 'Programs', 'Git', 'bin', 'bash.exe')); + candidates.push(path.join(home, 'AppData', 'Local', 'Programs', 'Git', 'bin', 'bash.exe')); + for (const candidate of candidates) { + if (fs.existsSync(candidate)) return candidate; + } + + const installPath = readInstallPath(); + if (installPath) { + const candidate = path.join(installPath, 'bin', 'bash.exe'); + if (fs.existsSync(candidate)) return candidate; + } + return null; +} + +let _winBashLauncherCache: string | undefined; + +/** + * The shell word to emit in a rendered hook command. POSIX keeps the bare + * `bash`; Windows substitutes the resolved Git Bash path (quoted, forward + * slashes so it stays JSON-safe — the default install location contains a + * space) so the command never reaches the WSL launcher. Falls back to + * plain `bash` only when Git is absent — there the old form was already + * dead anyway. + */ +function getHookShellCommand(): string { + if (process.platform !== 'win32') return 'bash'; + if (_winBashLauncherCache === undefined) { + const found = findGitBashWindows(); + if (found) { + _winBashLauncherCache = `"${found.split(path.sep).join('/')}"`; + } else { + _winBashLauncherCache = 'bash'; + log.debug('teamai hooks: Git Bash not found on this Windows machine; hook commands keep bare `bash` and may resolve to the WSL launcher.'); + } + } + return _winBashLauncherCache; +} + /** Generate the hook-dispatch command for a given event, tool, and optional matcher. */ export function getDispatchCommand(event: string, tool: string, matcher?: string, binPath?: string): string { const bin = binPath ?? 'teamai'; const matcherArg = matcher && matcher !== '*' ? ` --matcher ${matcher}` : ''; - return `bash -lc "${bin} hook-dispatch ${event} --tool ${tool}${matcherArg} 2>/dev/null" || true`; + return `${getHookShellCommand()} -lc "${bin} hook-dispatch ${event} --tool ${tool}${matcherArg} 2>/dev/null" || true`; } /** diff --git a/src/hooks.ts b/src/hooks.ts index 7d3cfa3e9..83d45a932 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -345,12 +345,22 @@ function toCodexEntry(def: HookDef): CodexHookMatcher { return entry; } -const COPILOT_BUILTIN_COMMAND_RE = /^bash -lc "(teamai hook-dispatch [^"]+) 2>\/dev\/null" \|\| true$/; +// getDispatchCommand() prefixes the launcher with a quoted, forward-slash Git +// Bash path on Windows and keeps bare `bash` everywhere else (and on Windows +// machines where Git Bash cannot be found). +const COPILOT_BUILTIN_COMMAND_RE = /^("[^"]+"|bash) -lc "(teamai hook-dispatch [^"]+) 2>\/dev\/null" \|\| true$/; /** Render a valid PowerShell equivalent for TeamAI's generated bash wrapper. */ function copilotPowershellCommand(command: string): string { const match = command.match(COPILOT_BUILTIN_COMMAND_RE); - return match ? `${match[1]} 2>$null; exit 0` : command; + if (!match) return command; + // PowerShell needs the call operator before a quoted executable path, and + // `|| true` maps to `; exit 0`. The dispatch command inside is + // builtin-generated (no `$`, backticks or double quotes), so echoing it + // inside a double-quoted PowerShell string is interpolation-safe. + return match[1] === 'bash' + ? `${match[2]} 2>$null; exit 0` + : `& ${match[1]} -lc "${match[2]} 2>/dev/null"; exit 0`; } function toCopilotEntry(def: HookDef): CopilotHookEntry {