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
49 changes: 30 additions & 19 deletions docs/windows-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,17 @@

## TL;DR

On Windows the hooks TeamAI injects use a bare `bash` launcher that silently
crashes (the WSL `bash` ships Node 18, which can't parse the TeamAI bundle), so
the hooks are effectively dead — `|| true` hides the failure. In addition,
`codebuddy` / `workbuddy` hooks are **never written at all** because TeamAI's
shell detection (`fs.existsSync('/bin/sh')`) is always false on Windows.
On older TeamAI versions the hooks injected on Windows used a bare `bash`
launcher that silently crashes (the WSL `bash` ships Node 18, which can't parse
the TeamAI bundle), so the hooks were effectively dead — `|| true` hid the
failure. The same versions never wrote `codebuddy` / `workbuddy` hooks at all,
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.

The durable user-side fix combines two mechanisms so hooks fire no matter what
`teamai` writes:
Expand Down Expand Up @@ -51,7 +57,7 @@ bundle needs a newer Node, so every hook invocation crashes silently. Because
the command ends in `|| true`, the crash is swallowed and nothing is logged —
hooks never fire, yet `teamai doctor` still reports them as "present".

### Failure mode 2 — `hasShell()` skips CodeBuddy / WorkBuddy
### Failure mode 2 — `hasShell()` skipped CodeBuddy / WorkBuddy

`src/builtin-hooks.ts` gates shell-dependent tools on `hasShell()`:

Expand All @@ -69,14 +75,16 @@ export function hasShell(): boolean {
```

`/bin/sh` does not exist on Windows, so `hasShell()` is `false` and
`skipToolsWithoutShell()` adds `codebuddy` / `workbuddy`
(`SHELL_DEPENDENT_TOOLS`) to the skip set. Those two agents get **no hooks at
all** on Windows, even when everything else works.
`skipToolsWithoutShell()` added `codebuddy` / `workbuddy`
(`SHELL_DEPENDENT_TOOLS`) to the skip set — those two agents got **no hooks at
all** on Windows, even when everything else worked.

> Note: `workbuddy` has a partial escape hatch — `hasShellFor()` returns `true`
> if `bundledShellFor(tool)` finds WorkBuddy's bundled PortableGit `sh.exe`. But
> that only helps if that exact binary is present, and `codebuddy` has no
> bundled shell, so it is skipped unconditionally on Windows.
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.

---

Expand All @@ -85,7 +93,8 @@ all** on Windows, even when everything else works.
1. **Bare `bash` → WSL Node 18.** Windows `PATH` resolves `bash` to the WSL
launcher before Git Bash; WSL Node 18 can't parse the TeamAI bundle.
2. **`hasShell()` Windows bug.** `fs.existsSync('/bin/sh')` is never true on
Windows, so hook injection for `codebuddy` / `workbuddy` is skipped.
Windows, which used to skip hook injection for `codebuddy` / `workbuddy`;
the per-tool `bundledShellFor()` resolver now covers them.
3. **WSL path translation.** A WSL-side wrapper that `exec`s the Windows Node
with a `/mnt/c/...` path gets mangled into `C:\mnt\c\...`, causing
`MODULE_NOT_FOUND`.
Expand Down Expand Up @@ -183,9 +192,9 @@ dispatch (bare bash/WSL): claude=0 codex=0 zcode=0 codebuddy=0 qoder=0 workbuddy
break again.
- **Requires WSL for Mechanism B.** On a machine without WSL, only Mechanism A
(the Git-Bash absolute path currently in the files) works.
- **Does not patch TeamAI itself.** The upstream `hasShell()` bug and the
bare-`bash` default are not fixed inside `teamai-cli`. After
`npm update teamai-cli` re-apply this fix (or rely on the WSL wrapper).
- **Does not patch older TeamAI versions.** The upstream `hasShell()` skip and
the bare-`bash` default are fixed in current `teamai-cli`; `npm update
teamai-cli` picks the fixes up and the workaround can then be dropped.
- **macOS / Linux need no fix.** There, bare `bash` already resolves to the
system Node and works natively.
- **`teamai doctor` `gh` check can be a false negative.** It may spawn `gh`
Expand All @@ -198,7 +207,8 @@ dispatch (bare bash/WSL): claude=0 codex=0 zcode=0 codebuddy=0 qoder=0 workbuddy

## Suggested upstream fix (for maintainers)

Two small changes would make Windows work out of the box:
Two small changes would make Windows work out of the box — both have since
shipped in `teamai-cli`, so this section is kept for context:

### 1. Make `hasShell()` Windows-aware

Expand Down Expand Up @@ -231,7 +241,8 @@ export function hasShell(): boolean {
}
```

This alone would let `codebuddy` / `workbuddy` hooks be injected on Windows.
Current `teamai` achieves this through `hasShellFor()` → `bundledShellFor()`, so
`codebuddy` / `workbuddy` hooks are injected on Windows today.

### 2. Default the dispatch command to an absolute Git Bash path on Windows

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

## 摘要(TL;DR)

在 Windows 上,TeamAI 注入的钩子使用裸 `bash` 启动器,会**静默崩溃**(WSL 的
`bash` 自带 Node 18,无法解析 TeamAI 打包产物),因此钩子实际上处于失效状态——
`|| true` 把错误吞掉了。此外,`codebuddy` / `workbuddy` 的钩子**根本不会被写入**,
因为 TeamAI 的 shell 检测(`fs.existsSync('/bin/sh')`)在 Windows 上永远为
假。
在较早版本的 TeamAI 上,Windows 注入的钩子使用裸 `bash` 启动器,会**静默崩溃**
(WSL 的 `bash` 自带 Node 18,无法解析 TeamAI 打包产物),因此钩子实际上处于失效
状态——`|| true` 把错误吞掉了。同样的版本中,`codebuddy` / `workbuddy` 的钩子
**根本不会被写入**,因为 shell 检测(`fs.existsSync('/bin/sh')`)在 Windows 上
永远为假。

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

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

Expand Down Expand Up @@ -47,7 +52,7 @@
结尾,崩溃被吞掉、不记录日志——钩子永不触发,但 `teamai doctor` 仍报告它们
“存在”。

### 故障模式 2 — `hasShell()` 跳过 CodeBuddy / WorkBuddy
### 故障模式 2 — `hasShell()` 曾跳过 CodeBuddy / WorkBuddy

`src/builtin-hooks.ts` 用 `hasShell()` 来门控依赖 shell 的工具:

Expand All @@ -65,13 +70,15 @@ export function hasShell(): boolean {
```

`/bin/sh` 在 Windows 上不存在,因此 `hasShell()` 为 `false`,`skipToolsWithoutShell()`
会把 `codebuddy` / `workbuddy`(`SHELL_DEPENDENT_TOOLS`)加入跳过集合这两个代理
曾把 `codebuddy` / `workbuddy`(`SHELL_DEPENDENT_TOOLS`)加入跳过集合这两个代理
在 Windows 上**完全不会获得钩子**,即使其他一切都正常。

> 说明:`workbuddy` 有一个局部逃生通道——若 `bundledShellFor(tool)` 找到了
> WorkBuddy 自带的 PortableGit `sh.exe`,`hasShellFor()` 会返回 `true`。但这仅在
> 该二进制确实存在时才有用,而 `codebuddy` 没有自带 shell,因此在 Windows 上会被
> 无条件跳过。
该跳过已不再存在:门控会先向每个工具询问其自身的钩子 shell
(`hasShellFor()` → `bundledShellFor()`)。`workbuddy` 通过其自带的 PortableGit
`sh.exe` 解析;`codebuddy` 通过 cmd.exe 解析——CodeBuddy 在 Windows 上的钩子运行器
是 `%ComSpec%`(它通过 `child_process.spawn(command, [], { shell: true })` 执行钩子
的 `command`),而任何 Windows 安装都提供 cmd.exe。只有无法解析出 shell 的工具才会
被跳过。

---

Expand All @@ -80,7 +87,8 @@ export function hasShell(): boolean {
1. **裸 `bash` → WSL Node 18.** Windows 的 `PATH` 会把 `bash` 解析到 WSL 启动器,
而非 Git Bash;WSL 的 Node 18 无法解析 TeamAI 打包产物。
2. **`hasShell()` 的 Windows bug.** `fs.existsSync('/bin/sh')` 在 Windows 上永远为假,
因此跳过 `codebuddy` / `workbuddy` 的钩子注入。
过去会跳过 `codebuddy` / `workbuddy` 的钩子注入;现在由按工具的
`bundledShellFor()` 解析器覆盖它们。
3. **WSL 路径转换.** 在 WSL 侧用 `/mnt/c/...` 路径 `exec` Windows Node 的包装脚本会被
改写成 `C:\mnt\c\...`,导致 `MODULE_NOT_FOUND`。

Expand Down Expand Up @@ -173,9 +181,8 @@ dispatch (裸 bash/WSL): claude=0 codex=0 zcode=0 codebuddy=0 qoder=0 workbuddy
覆盖,机制 B 仅在 **WSL 保持安装** 时有效。若移除 WSL,裸 `bash` 钩子会再次失效。
- **机制 B 需要 WSL.** 在没有 WSL 的机器上,只有机制 A(当前配置文件中的 Git Bash
绝对路径)可用。
- **不会修补 TeamAI 本身.** 上游的 `hasShell()` bug 与裸 `bash` 默认值并未在
`teamai-cli` 内部修复。执行 `npm update teamai-cli` 后需重新应用本修复(或依赖
WSL 包装脚本)。
- **不会修补旧版 TeamAI.** 上游的 `hasShell()` 跳过与裸 `bash` 默认值已在当前
`teamai-cli` 中修复;执行 `npm update teamai-cli` 即可获得,之后可移除本绕行方案。
- **macOS / Linux 无需修复.** 在这些系统上,裸 `bash` 已解析到系统 Node,原生可用。
- **`teamai doctor` 的 `gh` 检查可能是误报.** 它可能在没有 `APPDATA` 的情况下启动
`gh`,因此即使 `gh auth status` 显示已登录,它也看不到登录状态。若其他检查均通过,
Expand All @@ -186,7 +193,8 @@ dispatch (裸 bash/WSL): claude=0 codex=0 zcode=0 codebuddy=0 qoder=0 workbuddy

## 给维护者的修复建议(上游)

两处小改动即可让 Windows 开箱即用:
两处小改动即可让 Windows 开箱即用——目前均已在 `teamai-cli` 中落地,本节保留作背景
说明:

### 1. 让 `hasShell()` 感知 Windows

Expand Down Expand Up @@ -218,7 +226,8 @@ export function hasShell(): boolean {
}
```

仅此一项改动即可让 `codebuddy` / `workbuddy` 的钩子在 Windows 上被注入。
当前 `teamai` 通过 `hasShellFor()` → `bundledShellFor()` 实现了这一点,因此
`codebuddy` / `workbuddy` 的钩子如今会在 Windows 上被注入。

### 2. 在 Windows 上把 dispatch 命令默认指向 Git Bash 绝对路径

Expand Down
19 changes: 16 additions & 3 deletions src/__tests__/hooks-golden.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@ import { injectHooks } from '../hooks.js';
// 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.
// 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.
const fixturesDir = path.resolve(__dirname, 'fixtures', 'hooks');

// true = the command carries the dispatch shell prefix (`getDispatchCommand`).
Expand All @@ -30,6 +31,16 @@ 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 @@ -40,7 +51,9 @@ describe('hooks golden — built-in output is byte-identical to the captured bas
});

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

vi.mock('../utils/logger.js', () => ({
Expand Down Expand Up @@ -369,3 +370,143 @@ describe('reconcileTeamHooksForConfig — legacy projectRoot sweep', () => {
expect(claude.hooks.SessionStart).toHaveLength(1);
});
});

// ── Project gate rendering per host shell ────────────────────
//
// 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', () => {
const codebuddyOnly = {
toolPaths: { codebuddy: { settings: '.codebuddy/settings.json' } },
} as unknown as TeamaiConfig;

async function teamStopCommands(file: string): Promise<string[]> {
const settings = await fse.readJson(path.join(home, file));
return (settings.hooks.Stop ?? [])
.filter((e: { description?: string }) => e.description?.startsWith('[teamai:hook:'))
.map((e: { hooks: Array<{ command: string }> }) => e.hooks[0].command);
}

const telemetryYaml = (tool: string): string => `
hooks:
- id: telemetry
description: inject telemetry
event: Stop
matcher: "*"
command: python3 .docs/script/inject-telemetry.py
tools: [${tool}]
`;

it('renders a cmd.exe gate for a tool whose Windows hook runner is cmd.exe', 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');
} finally {
platformSpy.mockRestore();
}
});

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');
expect(command.startsWith('if [ "$PWD" = ')).toBe(true);
expect(command.endsWith('); fi')).toBe(true);
} finally {
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> {
await writeYaml(`
hooks:
- id: gate
description: gate probe
event: Stop
command: echo TEAMAI_GATE_PAYLOAD
tools: [codebuddy]
`);
await fse.ensureDir(path.join(sandboxHome, '.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];
}

/** 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 ?? '' };
}

it.skipIf(process.platform !== 'win32')(
'fires only inside the project and never executes part of the path',
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);
}
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);
}
},
);
});
Loading
Loading