Skip to content

fix(hooks): give the Codex team-hook dispatcher a Windows command (#1209) - #1210

Merged
SaulMoro merged 2 commits into
Tencent:mainfrom
kittimzhe:fix/team-hooks-command-windows-1209
Oct 21, 2026
Merged

SaulMoro merged 2 commits into
Tencent:mainfrom
kittimzhe:fix/team-hooks-command-windows-1209

Conversation

@kittimzhe

Copy link
Copy Markdown
Contributor

What

On Windows, Codex runs hook commands with PowerShell. Built-in entries have carried their PowerShell form in commandWindows since #1074; the team-hook dispatcher entries never did — getTeamHookDispatchCommand renders the POSIX wrapper on every platform, so on Windows reconcileCodexDispatchers writes an entry whose command is a quoted Git Bash path (or bare bash, which resolves to the WSL launcher). Either is a ParserError in Codex's PowerShell, so the team hooks never run — the remaining half of #1075/#1127 (#1209).

Change

The dispatcher entry now carries a commandWindows beside its unchanged POSIX command, following the built-ins' contract (#1074): the entry stays byte-identical across platforms, and stays removable by the same command match (isCodexTeamHookDispatcher reads command only).

The Windows form is deliberately not the built-in fail-open shape (2>$null; exit 0):

teamai hook-dispatch <E> --tool codex --team-hooks; exit $LASTEXITCODE

A dispatcher must pass what its bash form passes — the exit status (2 blocks the run) and stderr — so nothing is redirected and the exit code rides $LASTEXITCODE. (The sketch on the issue redirects stderr with 2>$null; that would discard the block message.)

Verification

  • Red first (codex-team-hooks.test.ts): reconcile writes the dispatcher entry with the POSIX command and the block-preserving commandWindows; a second run writes the same bytes.
  • Dispatcher e2e (codex-team-hook-dispatcher-915.test.ts), 12/12 through the built CLI: after a real pull, every dispatcher entry in ~/.codex/hooks.json carries the expected commandWindows, and the POSIX form still dispatches with exit code and stderr.
  • Adjacent suites 86/86 (hooks, pull-codex-trust, muse), tsc and lint clean.
  • The PowerShell semantics themselves (exit-code passthrough, stderr not swallowed) could not be executed on macOS, where pwsh is absent — @natinli a run on your Windows machine would confirm the fix end to end.

Fixes #1209

…ncent#1209)

On Windows, Codex runs hook commands with PowerShell, and the built-in
entries have carried their PowerShell form in commandWindows since
Tencent#1074. The team-hook dispatcher entries never did: getTeamHookDispatch
Command renders the POSIX wrapper on every platform, so on Windows
reconcileCodexDispatchers writes an entry whose command is a quoted Git
Bash path (or bare bash, which resolves to the WSL launcher) - a
ParserError in Codex's PowerShell, so team hooks never run.

The dispatcher entry now carries a commandWindows beside its unchanged
POSIX command, following the same contract as the built-ins: the entry
stays byte-identical across platforms and removable by the same command
match. The Windows form is deliberately not the built-in fail-open
shape (2>$null; exit 0): a dispatcher must pass what its bash form
passes - the exit status (2 blocks the run) and stderr - so nothing is
redirected and the exit code rides $LASTEXITCODE.

The PowerShell semantics (exit-code passthrough, stderr not swallowed)
could not be run on macOS, where pwsh is absent; the POSIX form is
exercised end-to-end by the dispatcher e2e.
@jeff-r2026 jeff-r2026 self-assigned this Oct 19, 2026
@github-actions

Copy link
Copy Markdown

No findings in the specified diff.

The PR description documents sufficient testing: 12/12 dispatcher E2E tests through the built CLI, including a real pull, plus adjacent suites, type checking, and lint.

PowerShell execution remains unverified, but the author explicitly identifies that limitation; it is not blocking under the review rules. No PR code was run, built, or installed.

@STiFLeR7 STiFLeR7 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary. On Windows, Codex runs hook commands through PowerShell, but the team-hook dispatcher only ever wrote its POSIX bash -lc "..." form (unlike the built-in hook entries, which got a commandWindows sibling in #1074). PowerShell rejects that as a ParserError, so team hooks silently never ran on Windows. This PR adds commandWindows to the dispatcher entry, carrying teamai hook-dispatch <event> --tool codex --team-hooks; exit $LASTEXITCODE — deliberately not the built-ins' fail-open shape (2>$null; exit 0), since a dispatcher has to pass through the block exit code (2) and stderr, not swallow them.

Verified. (Windows 11, real PowerShell 7 and Windows PowerShell 5.1, not a mock)
You flagged in the PR body that the PowerShell semantics couldn't be exercised on macOS (no pwsh) and asked for a Windows run to confirm end to end — ran that directly:

  • Built a .cmd shim standing in for a real npm-installed teamai binary (same shim shape Windows gives any globally-installed Node CLI) that writes to stderr and exits 2, then ran it through the exact dispatcher line: faketeamai hook-dispatch Stop --tool codex --team-hooks; exit $LASTEXITCODE.
  • Confirmed both halves of the claim for real: the stderr message passed through uninterrupted (not swallowed), and the final process exit code was 2, matching the block contract described in the PR.
  • Also spot-checked the unit test's claim directly against builtin-hooks.ts/codex-team-hooks.ts: commandWindows only gets attached to the dispatcher entry (getTeamHookDispatchCommandWindows), the command field stays the POSIX form on every platform per #1074's contract, and isCodexTeamHookDispatcher's removal match still reads command only — so the entry stays recognizable and removable exactly as before.

Findings. None — I went in looking for a defect and didn't find one. The design choice (not reusing the built-ins' fail-open shape) is correct and well-reasoned: 2>$null; exit 0 would genuinely discard the block message and the exit code, which is precisely the bug this PR is fixing.

Not covered / unverified. I didn't run this through a real Codex install end-to-end (only the PowerShell snippet in isolation plus the source-level checks above); didn't test on PowerShell Core on Linux/macOS since the dispatcher's Windows path only matters on Windows anyway.

Verdict. Comment (resolving your explicit "please confirm on Windows" ask, not a formal approval). Confirmed: the stderr-and-exit-code contract works exactly as described on real Windows PowerShell, both 5.1 and 7.

@kittimzhe

Copy link
Copy Markdown
Contributor Author

Flagging for the maintainers before this goes further: @natinli's field-level finding on #1209 — Codex's hook config struct (HookHandlerConfig::Command: command, timeout, async, statusMessage, agent) has no commandWindows field, and a binary search for the string returns zero hits: serde drops the unknown key, so Codex executes the POSIX command on Windows regardless. That makes #1074's contract a no-op for Codex — for the built-ins (#1075) and for this PR's dispatcher entry identically.

Two things this PR can still claim, and one it can't:

  • Still true: the dispatcher entry was the one Codex-family entry with no Windows form at all; this adds the same sibling the built-ins carry, keeping the cross-platform byte-stability contract ([bug] Codex hook entries on Windows are rendered in bash syntax and silently never run #1074) and the removal match intact.
  • Still true (independently verified by @STiFLeR7 on real Windows PowerShell 5.1 and 7): the command's PowerShell semantics hold — stderr passes, exit 2 blocks.
  • No longer claimable: that this makes team hooks run on Windows under Codex. If the reader ignores the field, both the built-in and dispatcher entries keep hitting the same ParserError.

If the durable fix is a platform-neutral bare command (possible for the dispatcher — its line has no shell syntax; harder for built-ins, whose fail-open is shell syntax by nature, unless it moves into hook-dispatch itself), this PR is superseded and I'm happy to close it in favor of that redesign. If commandWindows stays the contract for the tools that do read it, this PR completes it for the dispatcher. Your call — natinli is filing the reader-side evidence as a separate issue either way.

@SaulMoro

Copy link
Copy Markdown
Collaborator

@kittimzhe Checked Codex's source: it does read commandWindows. HookHandlerConfig::Command declares it with #[serde(rename = "commandWindows")] (codex-rs/config/src/hook_config.rs:144 at rust-v0.142.5), and discovery picks it on Windows via command_windows.unwrap_or(command) (codex-rs/hooks/src/engine/discovery.rs:470). Support landed in openai/codex#22159 (May 2026) and is in 0.142.5. So #1074's contract holds for Codex, and this PR completes it for the dispatcher. Keeping it open.

@SaulMoro

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

A correction to my comment above. Codex does read commandWindows, but on Windows it runs hooks through %COMSPEC% /C, which is cmd.exe and not PowerShell (codex-rs/hooks/src/engine/command_runner.rs:119-126 at rust-v0.142.5, details in #1215). That changes three things in this PR:

  • The tail is not doing anything. ; exit $LASTEXITCODE is not a command in cmd. It reaches teamai as extra arguments, and hook-dispatch accepts and ignores them ([hookArgs...] is only used for git hooks). So the entry probably works, with exit 2 and stderr passing through, but by accident.
  • No tail is needed. cmd /C already exits with the program's exit code, so the cmd form is just teamai hook-dispatch <event> --tool codex --team-hooks.
  • The comments and the record describe the wrong shell. The doc comment on getTeamHookDispatchCommandWindows, the test names and the PowerShell verification all assume PowerShell. That verification shows PowerShell semantics, not what Codex executes.

Suggested change: drop the tail, update the comments, and replace the PowerShell record with one from Codex on Windows, including the Codex version. The command has no quotes, so a cmd /C run is also representative.

This PR should own the dispatcher's commandWindows, and #1231 should limit itself to the built-ins (see my comment there), so the two PRs do not both rewrite the same entry.

@SaulMoro SaulMoro left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was generated by AI during triage.

Blocking until the changes in #1210 (comment) land. Codex runs Windows hooks under cmd.exe, so the commandWindows tail and its PowerShell record need reworking.

)

Codex runs hook commands through %COMSPEC% /C on Windows — cmd.exe, not
PowerShell (Tencent#1215) — so the '; exit $LASTEXITCODE' tail was never a
command: it reached hook-dispatch as junk arguments (accepted and
ignored, so the entry worked by accident). cmd /C already exits with
the program's exit code and passes stderr through, so the Windows form
is just the bare dispatch, without the POSIX form's bash wrapper that
cmd cannot resolve. Comments and tests updated to the real shell;
verification record to be re-run under cmd /C with the Codex version
noted.
kittimzhe added a commit to kittimzhe/teamai-cli that referenced this pull request Oct 21, 2026
Per the split agreed in review: the dispatcher entry belongs to Tencent#1210,
so this PR no longer rewrites it — reconcileCodexDispatchers,
getTeamHookDispatchCommand and DISPATCHER_COMMAND are back to main's
state. What remains is the built-in half: the bare portable command
plus the cmd.exe commandWindows (6124e9c).
@kittimzhe

Copy link
Copy Markdown
Contributor Author

Agreed on the split — this PR owns the dispatcher's Windows side, and #1231 is now built-ins only (dispatcher hunks stripped there). Reworked per your reading (34f2559): the commandWindows is the bare dispatch — no tail, no redirect — because cmd /C propagates the exit code and stderr natively; the POSIX command keeps the bash wrapper as on main. Comments and tests updated to cmd.exe semantics. One gap I can't close locally (no Windows machine): the verification record needs a real cmd /C run with the Codex version noted — @natinli or @STiFLeR7, could either of you capture it? The command has no quotes, so a plain cmd /C run is representative.

@github-actions

Copy link
Copy Markdown

No findings in the specified diff.

The PR description documents sufficient testing: 12/12 dispatcher E2E tests through the built CLI, including a real pull, plus adjacent suites, type checking, and lint. Unverified Windows execution is explicitly disclosed and is not blocking under the review rules.

The earlier pass raised no findings, so none require resolution. No PR code was run, built, or installed.

@natinli

natinli commented Oct 21, 2026

Copy link
Copy Markdown

Windows record for the reworked dispatcher entry (34f2559), as requested.

Environment

OS Windows 11 Pro 10.0.26200
Codex (PATH — what teamai's trust uses) 0.142.5
Codex (desktop runtime) 0.163.0-alpha.10.2
Shell Codex uses for hooks %COMSPEC% /C (cmd.exe)

The string under test

getTeamHookDispatchCommandWindows on this branch emits:

teamai hook-dispatch Stop --tool codex --team-hooks

No quotes, no tail, no redirect — so a plain cmd /C run is representative, as you said.

Results

Run from a real cmd /C (batch file, so nothing is lost to Git Bash argument handling):

Case Output Exit
dispatch with --team-hooks, project with no team hooks (none) 0
dispatch with --team-hooks, plain home directory (none) 0
same string, re-run (idempotent) (none) 0

Exit-code passthrough

The property the bare form depends on, verified separately under cmd:

cmd> node -e "process.exit(2)"
     ERRORLEVEL = 2          ← propagated, not swallowed

So dropping the tail is safe in the sense that matters: cmd /C surfaces the child's status, and the dispatcher's exit 2 (block) reaches Codex intact. Stderr is likewise not redirected.

Caveats on what this covers

  • I could not exercise a real team-hook dispatch end to end: the scratch project was not teamai init-ed, so hook-dispatch --team-hooks found nothing to run and exited 0 without invoking a hook. The record above confirms the command shape runs cleanly under cmd and returns 0; it does not confirm a team hook's own exit 2 propagates through a full dispatch. That path is covered by your E2E tests on POSIX, and the exit-code test above is the Windows half of the same guarantee.
  • I did not run this through Codex itself (only cmd /C), per your note that the unquoted command makes the two equivalent.
  • Nothing on this machine was modified; the work was in a throwaway clone, since deleted.

One suggestion for the PR text

The doc comment could state why no tail is needed, since the previous form's tail looked deliberate: cmd /C returns the child's exit status and does not redirect stderr, so any shell-level fail-open tail would either be inert (as ; exit 0 was) or would destroy a blocking decision. That sentence would have saved me a round of confusion when reading the old form.

@SaulMoro SaulMoro left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was generated by AI during triage.

The rework covers the changes requested in #1210 (comment):

  • The tail is gone. commandWindows is now teamai hook-dispatch <event> --tool codex --team-hooks, and cmd /C runs it with the exit code and stderr intact.
  • The comments and test names now describe cmd.exe instead of PowerShell.
  • @natinli's record (#1210 (comment)) runs that exact string under cmd /C on Windows 11, with Codex 0.142.5 on PATH, and shows cmd /C returning a child's exit 2.

Checked locally at 34f2559 on macOS: npx tsc --noEmit is clean and codex-team-hooks.test.ts passes 7/7. After npm run build, codex-team-hook-dispatcher-915.test.ts passes 12/12. The branch still merges cleanly with current main. The trust pass needs no change for the new field, because teamaiCodexHooks already reads commandWindows from the file. Codex on Windows therefore lists the dispatcher under an id teamai asks it to trust.

Findings

  • [P2 non-blocking] The PR body still describes the first commit. The Change section shows the ; exit $LASTEXITCODE form and says Codex runs hooks with PowerShell, and Verification says PowerShell could not be run. A squash merge turns that text into the commit message. Please change it to the bare cmd form and paste or link natinli's Windows record in Verification.
  • [P3 nit] src/codex-team-hooks.ts:209-212 says the entry "keeps the POSIX form on every platform, like the built-in entries (#1074)". The dispatcher's command comes from getHookShellCommand(), which returns the machine's Git Bash path on Windows, while the built-ins use bare bash. Both are POSIX forms, but the bytes differ. This is harmless here because ~/.codex/hooks.json belongs to one user and is never committed, but "like the built-in entries" is inaccurate. The body's "stays byte-identical across platforms" makes the same claim.

Not covered

Nobody has run this through Codex itself on Windows, and no real team hook has returned exit 2 through the npm teamai.cmd shim under cmd /C. natinli's record checks the two parts separately: the command shape, and cmd's exit-code passthrough. That is enough to merge; a Windows user report can confirm the full path.

Verdict: Approve. This replaces my earlier request for changes.

@SaulMoro
SaulMoro merged commit 0da2be7 into Tencent:main Oct 21, 2026
14 checks passed
kittimzhe added a commit to kittimzhe/teamai-cli that referenced this pull request Oct 22, 2026
SaulMoro added a commit to SaulMoro/teamai-cli that referenced this pull request Oct 22, 2026
Tencent#1210 pinned the POSIX `bash -lc` rendering in codex-team-hooks.test.ts,
and Tencent#1221 put that file on the Windows test list. On Windows the
command renders the Git Bash path, and the Windows list guard rejects a
pinned POSIX rendering in a file that does not pin the platform. Every
Lint & Test leg and the Windows job on main fail.
jeff-r2026 pushed a commit that referenced this pull request Oct 23, 2026
…15% (#1283)

* fix(test): pin the platform in the Codex dispatcher render test

#1210 pinned the POSIX `bash -lc` rendering in codex-team-hooks.test.ts,
and #1221 put that file on the Windows test list. On Windows the
command renders the Git Bash path, and the Windows list guard rejects a
pinned POSIX rendering in a file that does not pin the platform. Every
Lint & Test leg and the Windows job on main fail.

* perf(ci): shard the internal E2E run

The full-surface E2E job ran every E2E file on one runner under the
fixture-repo lock, so it set the pace of every main push and internal
PR. Only e2e.test.ts uses the fixture repo: run that file alone under
the lock, and run the rest in the three credential-free shards on every
event.

* perf(test): shorten the exclude lock retry and driver poll in tests

Unit tests that hold the exclude lock wait out updateFileLocked's whole
retry budget, and the workflow driver tests wait out its 2 s poll.
Production keeps both values; the test files that pay for them shorten
them through _setExcludeLockRetryMs and _setDriverPollMs.

* perf(git-exclude): skip git for a directory that does not exist

findMcpGitExcludes asks where each agent's config directory is excluded,
including directories not created yet (.codex, .kiro/settings, .trae).
For each one locateExclude started git in a missing cwd, which always
failed, and the null answer is not cached, so every call paid for it
again. The answer is the same null; it now comes without the process.

mcp-reconcile.test.ts and uninstall.test.ts start 35% fewer git
processes (10918 to 7115) and run 13% and 9% faster locally.

* perf(ci): collect coverage only on the leg that uploads it

Five of the six unit legs ran with --coverage and discarded the report:
only Ubuntu on Node 20 uploads it, and no threshold reads it. In a paired
run on one macOS runner, coverage cost about 67 s of a 550-810 s leg.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[bug] Team hooks on Windows still render in bash syntax and never run under Codex

5 participants