Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions docs/windows-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ because shell detection (`fs.existsSync('/bin/sh')`) is always false on Windows.
Current `teamai` handles Windows itself, so the user-side workaround below is
only needed on an older version: hook commands launch through an absolute Git
Bash path, and each GUI tool resolves its own hook shell — WorkBuddy through its
bundled PortableGit `sh.exe`, and **CodeBuddy through cmd.exe** (`%ComSpec%`),
which every Windows install provides. Neither tool is skipped.
bundled PortableGit `sh.exe`, and **CodeBuddy through the Git Bash it requires**
on Windows. Neither tool is skipped.

The durable user-side fix combines two mechanisms so hooks fire no matter what
`teamai` writes:
Expand Down Expand Up @@ -80,11 +80,11 @@ export function hasShell(): boolean {
all** on Windows, even when everything else worked.

That skip is gone: gating now asks each tool for its own hook shell first
(`hasShellFor()` → `bundledShellFor()`). `workbuddy` resolves through the
PortableGit `sh.exe` it ships; `codebuddy` resolves through cmd.exe, because
CodeBuddy's Windows hook runner is `%ComSpec%` — it executes a hook's `command`
via `child_process.spawn(command, [], { shell: true })` — and every Windows
install provides cmd.exe. Only a tool with no resolvable shell is skipped.
(`hasShellFor()` → `bundledShellFor()`). Both resolve to a **POSIX** shell —
`workbuddy` to the PortableGit `sh.exe` it ships, `codebuddy` to the Git Bash it
requires on Windows and runs every hook's `command` through — so the rendered
hook commands are POSIX for both. Only a tool with no resolvable shell is
skipped.

---

Expand Down
11 changes: 5 additions & 6 deletions docs/windows-hooks.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@

当前版本的 `teamai` 已自行处理 Windows,因此下文的用户侧绕行方案仅在旧版本上需要:
钩子命令通过 Git Bash 的绝对路径启动,且每个 GUI 工具都会解析各自的钩子 shell——
WorkBuddy 使用其自带的 PortableGit `sh.exe`,**CodeBuddy 使用 cmd.exe**
(`%ComSpec%`),任何 Windows 安装都提供 cmd.exe。两者都不再被跳过。
WorkBuddy 使用其自带的 PortableGit `sh.exe`,**CodeBuddy 使用它在 Windows 上必需
的 Git Bash**。两者都不再被跳过。

持久化的用户侧修复结合两种机制,无论 `teamai` 写入什么都能让钩子触发:

Expand Down Expand Up @@ -75,10 +75,9 @@ export function hasShell(): boolean {

该跳过已不再存在:门控会先向每个工具询问其自身的钩子 shell
(`hasShellFor()` → `bundledShellFor()`)。`workbuddy` 通过其自带的 PortableGit
`sh.exe` 解析;`codebuddy` 通过 cmd.exe 解析——CodeBuddy 在 Windows 上的钩子运行器
是 `%ComSpec%`(它通过 `child_process.spawn(command, [], { shell: true })` 执行钩子
的 `command`),而任何 Windows 安装都提供 cmd.exe。只有无法解析出 shell 的工具才会
被跳过。
`sh.exe` 解析;`codebuddy` 解析到它在 Windows 上必需、并用来执行每条钩子 `command`
的 Git Bash。两者都是 **POSIX** shell,因此渲染出的钩子命令一律是 POSIX。只有无法
解析出 shell 的工具才会被跳过。

---

Expand Down
18 changes: 4 additions & 14 deletions src/__tests__/hooks-golden.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ import { injectHooks } from '../hooks.js';
// 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 for that reason; workbuddy
// stays machine-independent and keeps coverage. codebuddy also skips there, but
// for its own reason — see PLATFORM_SPECIFIC_TOOLS below.
// and codebuddy both stay machine-independent and keep coverage, because both
// render the wrapper form (`PATH="$HOME/.teamai/bin:$PATH" teamai …`), which
// never embeds a host path.
const fixturesDir = path.resolve(__dirname, 'fixtures', 'hooks');

// true = the command carries the dispatch shell prefix (`getDispatchCommand`).
Expand All @@ -31,16 +32,6 @@ const cases: Array<[string, string, boolean]> = [
['workbuddy', 'settings.json', false],
];

/**
* Tools whose rendered command is platform-specific for a reason other than the
* dispatch shell, so they have no cross-platform baseline: codebuddy is
* rendered in cmd.exe syntax on Windows (its hook runner there is cmd.exe, not
* a POSIX shell — see bundled-runtime.ts), exactly like ZCode, which is absent
* from the fixture set for the same reason. Its Windows shape is pinned by
* hooks-shell-check.test.ts instead, so the anchor stays platform-independent.
*/
const PLATFORM_SPECIFIC_TOOLS = new Set(['codebuddy']);

describe('hooks golden — built-in output is byte-identical to the captured baseline', () => {
let tmp: string;
beforeEach(async () => {
Expand All @@ -51,8 +42,7 @@ describe('hooks golden — built-in output is byte-identical to the captured bas
});

for (const [tool, file, usesDispatchCommand] of cases) {
const skip = process.platform === 'win32'
&& (usesDispatchCommand || PLATFORM_SPECIFIC_TOOLS.has(tool));
const skip = process.platform === 'win32' && usesDispatchCommand;
it.skipIf(skip)(`${tool} output matches golden fixture`, async () => {
const p = path.join(tmp, tool, file);
await injectHooks(p, tool);
Expand Down
216 changes: 142 additions & 74 deletions src/__tests__/hooks-reconcile-scope.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import path from 'node:path';
import os from 'node:os';
import { spawnSync } from 'node:child_process';
import fse from 'fs-extra';

vi.mock('../utils/git.js', async (importOriginal) => ({
Expand All @@ -15,6 +14,9 @@ vi.mock('../utils/logger.js', () => ({
}));

import { resolveAnchors, listWorktrees } from '../utils/git.js';
import { resetBundledRuntimeCache } from '../bundled-runtime.js';
import { findOnPath } from '../utils/lookpath.js';
import { spawn } from 'node:child_process';
import { CLAUDE_HOOK_OTHER_HOST_SKIP, reconcileTeamHooksForConfig } from '../hooks.js';
import * as gitHook from '../git-hook.js';
import type { LocalConfig, TeamaiConfig } from '../types.js';
Expand Down Expand Up @@ -924,13 +926,18 @@ describe('reconcileTeamHooksForConfig — legacy projectRoot sweep', () => {
});
});

// ── Project gate rendering per host shell ────────────────────
// ── Project gate: what it renders, and whether it actually fires ─
//
// A tool whose Windows hook runner is cmd.exe cannot execute a POSIX
// `if [ "$PWD" ... ]` gate: cmd aborts on that syntax, so the whole team hook —
// gate and payload alike — never runs. Pin the cmd rendering for those tools and
// the POSIX rendering for everything else.
describe('project gate rendering per host shell', () => {
// Every hook runner is a POSIX shell — CodeBuddy's Git Bash, WorkBuddy's
// bundled PortableGit, `bash -lc` for the rest — so the gate is always the
// POSIX form. A cmd.exe gate would not run there at all: `findstr` errors out
// and the `>nul` redirect leaves a file named `nul` in the project.
//
// On Windows that shell names its cwd `/c/proj`, never the `C:\proj` the root
// resolves to, so the gate carries both spellings. The cases below check the
// shape; the last one runs a rendered gate through a real shell, which is the
// only way a gate that can never match is caught.
describe('project gate — rendering and real-shell execution', () => {
const codebuddyOnly = {
toolPaths: { codebuddy: { settings: '.codebuddy/settings.json' } },
} as unknown as TeamaiConfig;
Expand All @@ -942,6 +949,34 @@ describe('project gate rendering per host shell', () => {
.map((e: { hooks: Array<{ command: string }> }) => e.hooks[0].command);
}

// findGitBashWindows() reads these off the real environment, so a Git
// installed on a non-standard drive (or found only via the HKLM registry key)
// would decide whether these cases run at all. Clear them and stage the one
// candidate under the mocked home instead, so the gate assertions below are
// the same on any developer box and on CI. WorkBuddy resolves its own MSYS
// sh under ~/.workbuddy, which needs no environment at all.
const winEnvKeys = ['ProgramFiles', 'ProgramFiles(x86)', 'LOCALAPPDATA'];
let savedEnv: Record<string, string | undefined>;

beforeEach(async () => {
savedEnv = {};
for (const key of winEnvKeys) {
savedEnv[key] = process.env[key];
delete process.env[key];
}
resetBundledRuntimeCache();
await fse.ensureFile(path.join(home, 'AppData', 'Local', 'Programs', 'Git', 'bin', 'bash.exe'));
await fse.ensureFile(path.join(home, '.workbuddy', 'binaries', 'PortableGit', 'versions', '9.9.9', 'bin', 'sh.exe'));
});

afterEach(async () => {
for (const key of winEnvKeys) {
if (savedEnv[key] === undefined) delete process.env[key];
else process.env[key] = savedEnv[key];
}
resetBundledRuntimeCache();
});

const telemetryYaml = (tool: string): string => `
hooks:
- id: telemetry
Expand All @@ -952,25 +987,31 @@ hooks:
tools: [${tool}]
`;

it('renders a cmd.exe gate for a tool whose Windows hook runner is cmd.exe', async () => {
it('renders the POSIX gate for codebuddy, which runs hooks through Git Bash', async () => {
const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32');
try {
await writeYaml(telemetryYaml('codebuddy'));
await fse.ensureDir(path.join(home, '.codebuddy'));
await reconcileTeamHooksForConfig(codebuddyOnly, localConfig());

const [command] = await teamStopCommands('.codebuddy/settings.json');
// `cd` prints the cwd into the pipe — never `%CD%` interpolated into a
// parsed command — and the root is caret-escaped inside `^"…^"` quotes.
expect(command.startsWith('cd| findstr /i /b /l /c:^"')).toBe(true);
expect(command).toContain(' >nul || cd| findstr /i /e /l /c:^"');
// Outside the project the gate must exit 0 (a non-zero status would make
// CodeBuddy treat UserPromptSubmit as allowed:false and block the prompt),
// while the payload's own status is passed through inside it.
expect(command.endsWith('^" >nul & if not errorlevel 1 (python3 .docs/script/inject-telemetry.py) else exit /b 0')).toBe(true);
expect(command).not.toContain('echo %CD%');
expect(command).not.toContain('&& (python3');
expect(command).not.toContain('$PWD');
expect(command.startsWith('if [ "$PWD" = ')).toBe(true);
expect(command.endsWith('); fi')).toBe(true);
// The runner names its cwd the MSYS way: on Windows that is `/c/...`,
// never the `C:\...` this root resolves to, so the gate must carry both
// spellings or it silently never fires. A root without a drive letter is
// already the shell's spelling and the gate carries a single test, which
// is what the suite's Linux and macOS jobs exercise.
if (/^[A-Za-z]:[\\/]/.test(project)) {
expect(command, 'a Windows root is tested in both spellings').toMatch(/\] \|\| \[ "\$PWD" = '\/[a-z]\//);
} else {
expect(command, 'a POSIX root needs one spelling').not.toContain(' || [ "$PWD" = ');
}
// 0.26.0 rendered a cmd.exe gate here. Git Bash cannot run it: `findstr`
// errors out, the gate never matches, and the `>nul` redirect leaves a
// file literally named `nul` in the project.
expect(command).not.toContain('findstr');
expect(command).not.toContain('exit /b 0');
} finally {
platformSpy.mockRestore();
}
Expand All @@ -993,77 +1034,104 @@ hooks:
platformSpy.mockRestore();
}
});
});

// ── The rendered cmd gate, executed by a real cmd.exe ────────
//
// The assertions above pin only the shape of the gate. These run it in real
// directories whose names carry the characters cmd.exe re-parses — `&`, which
// otherwise executes the rest of the directory name, plus `^`, `%` and a space
// — because a gate that merely looks right can still run part of a path as a
// command or silently stop matching. Windows-only: cmd.exe is the point.
describe('project gate — real cmd.exe execution (win32)', () => {
const codebuddyOnly = {
toolPaths: { codebuddy: { settings: '.codebuddy/settings.json' } },
} as unknown as TeamaiConfig;

/** Render the gate for `root`, then run it from `cwd` through cmd.exe. */
async function renderGate(root: string, sandboxHome: string): Promise<string> {
/**
* A real POSIX shell to run the gate with. Deliberately not the placeholder
* `bash.exe` staged above: that file only has to make
* `resolveCodebuddyShell()` report a shell and cannot execute anything. This
* is the MSYS bash the machine actually has on PATH, found through the real
* environment the staging did not touch.
*/
const executor = process.platform === 'win32' ? findOnPath('bash') : '/bin/sh';

/**
* Where these cases build their directories — deliberately outside
* `os.tmpdir()`. MSYS mounts the Windows temp directory at `/tmp`, so a root
* under it is reported as `/tmp/...` and no gate rendered from its native
* path can ever match it. `node_modules` sits on the workspace drive, which
* is what a real project looks like, and is never committed.
*/
const execBase = path.join(process.cwd(), 'node_modules');

/** Render the gate for `root` and read it back off the tool's settings file. */
async function renderGate(root: string): Promise<string> {
// A fresh file per call: entries for another root are kept by design, and
// this reads back exactly the gate just rendered.
await fse.remove(path.join(home, '.codebuddy', 'settings.json'));
await writeYaml(`
hooks:
- id: gate
description: gate probe
event: Stop
matcher: "*"
command: echo TEAMAI_GATE_PAYLOAD
tools: [codebuddy]
`);
await fse.ensureDir(path.join(sandboxHome, '.codebuddy'));
await fse.ensureDir(path.join(home, '.codebuddy'));
await reconcileTeamHooksForConfig(codebuddyOnly, { ...localConfig(), projectRoot: root } as LocalConfig);
const settings = await fse.readJson(path.join(sandboxHome, '.codebuddy', 'settings.json'));
const commands = (settings.hooks.Stop ?? [])
.filter((e: { description?: string }) => e.description?.startsWith('[teamai:hook:'))
.map((e: { hooks: Array<{ command: string }> }) => e.hooks[0].command);
expect(commands).toHaveLength(1);
return commands[0];
const [command] = await teamStopCommands('.codebuddy/settings.json');
expect(command).toBeDefined();
return command;
}

/** Run a rendered hook command the way CodeBuddy's hook runner does. */
function runCommand(command: string, cwd: string): { status: number | null; stdout: string } {
const result = spawnSync(command, { cwd, shell: true, encoding: 'utf8' });
return { status: result.status, stdout: result.stdout ?? '' };
/**
* Run a rendered hook command the way the runner does: through its shell.
* Async rather than `spawnSync` because a sandbox can fail the synchronous
* spawn with EBUSY, which would show up here as an empty stdout — the exact
* signature of a gate that never matched.
*/
function runCommand(command: string, cwd: string): Promise<{ status: number | null; stdout: string }> {
return new Promise((resolve) => {
const child = spawn(executor!, ['-c', command], { cwd });
let stdout = '';
child.stdout.on('data', (chunk: Buffer) => { stdout += chunk; });
child.on('close', (status) => resolve({ status, stdout }));
child.on('error', () => resolve({ status: null, stdout }));
});
}

it.skipIf(process.platform !== 'win32')(
'fires only inside the project and never executes part of the path',
it.skipIf(!executor)(
'fires inside the project and stays an exit-0 no-op outside it, under a real shell',
async () => {
for (const name of ['plain', 'sp&x', 'a^b', 'a%b', 'a%TEMP%b', 'sp ace', 'x&echo CANARY&y']) {
const root = path.join(project, name);
const sub = path.join(root, 'sub');
const sibling = path.join(project, `${name}-sibling`);
await fse.ensureDir(sub);
await fse.ensureDir(sibling);
// A fresh HOME per project keeps the shared settings file free of the
// previous iteration's project-scoped entries.
const sandboxHome = path.join(project, 'home', name);
vi.stubEnv('HOME', sandboxHome);
const command = await renderGate(root, sandboxHome);

for (const cwd of [root, sub]) {
const { status, stdout } = runCommand(command, cwd);
expect(stdout, `${name} inside ${cwd}`).toContain('TEAMAI_GATE_PAYLOAD');
expect(status, `${name} inside ${cwd}`).toBe(0);
const execRoot = await fse.mkdtemp(path.join(execBase, '.teamai-gate-'));
try {
// `sp&x` and `x&echo CANARY&y` carry the characters that would split the
// command if the gate did not quote the root; `sp ace` covers the space.
for (const name of ['plain', 'sp&x', 'sp ace', 'x&echo CANARY&y']) {
const root = path.join(execRoot, name);
const sub = path.join(root, 'sub');
const sibling = path.join(execRoot, `${name}-sibling`);
await fse.ensureDir(sub);
await fse.ensureDir(sibling);
const command = await renderGate(root);

// Inside: the gate matches and the payload runs. A gate rendered in
// the native spelling only would silently never do this.
for (const cwd of [root, sub]) {
const { status, stdout } = await runCommand(command, cwd);
expect(stdout, `${name} inside ${cwd}`).toContain('TEAMAI_GATE_PAYLOAD');
expect(status, `${name} inside ${cwd}`).toBe(0);
// A `&` in the directory name must never split the gate into
// commands — the payload's own output is the canary for that.
expect(stdout, `${name} injection canary`).not.toMatch(/^\s*CANARY\s*$/m);
}
// Outside: nothing runs, and the gate still exits 0 — CodeBuddy reads
// a non-zero hook status as `allowed:false` and would block every
// prompt typed outside the project.
for (const cwd of [execRoot, sibling]) {
const { status, stdout } = await runCommand(command, cwd);
expect(stdout, `${name} outside ${cwd}`).not.toContain('TEAMAI_GATE_PAYLOAD');
expect(status, `${name} outside ${cwd}`).toBe(0);
}
}
for (const cwd of [project, sibling]) {
const { status, stdout } = runCommand(command, cwd);
expect(stdout, `${name} outside ${cwd}`).not.toContain('TEAMAI_GATE_PAYLOAD');
// A mismatch must stay an exit-0 no-op: CodeBuddy reads a non-zero
// hook status as allowed:false and would block every prompt typed
// outside the project.
expect(status, `${name} outside ${cwd}`).toBe(0);
}
// `&` in the directory name must never split the gate into commands.
expect(runCommand(command, root).stdout, `${name} injection canary`).not.toMatch(/^\s*CANARY\s*$/m);
} finally {
// Some sandboxes refuse the bulk delete; a leftover empty directory
// under node_modules is harmless.
await fse.remove(execRoot).catch(() => {});
}
},
// A Windows runner starts a fresh MSYS bash per call, which is far slower
// than the cmd.exe the gate used to be tested with.
60_000,
);
});
});
Loading
Loading