Repository navigation
fix(plugin): run the Codex Windows hooks through Git Bash, not the WSL launcher - #4008
Conversation
Codex resolves a hook `command` through the platform shell — `/bin/sh -lc` on Unix, `cmd.exe /C` on Windows. Under cmd.exe the leading bare `bash` resolves through the Windows PATH to System32\bash.exe, the WSL launcher rather than Git Bash, which refuses to start in a sandboxed session (Bash/Service/CreateInstance/E_ACCESSDENIED). Every prompt, permission request, turn end, and session end then raised a "Hook failed" banner. Each hook now carries Codex's per-handler `commandWindows`, which replaces `command` on Windows. It calls a new cmd.exe shim, hooks/wt.cmd, that mirrors wt.sh's resolution — WORKTRUNK_BIN, else git-wt.exe, else a `wt.exe` PATH entry that isn't Windows Terminal's app alias — and tails with `|| exit /b 0` so a missing install stays best-effort, as `|| true` does on Unix. Closes #4007
worktrunk-bot
left a comment
There was a problem hiding this comment.
Verified the mechanism against the upstream sources the PR cites, since the whole change rests on them: HookHandlerConfig::Command in codex-rs/config/src/hook_config.rs carries #[serde(default, rename = "commandWindows", alias = "command_windows")], discovery.rs does command_windows.unwrap_or(command) under cfg!(windows), its substitution fold is command.replace(&format!("${{{key}}}"), value) — braced form only — and command_runner.rs spawns cmd.exe /C with raw_arg(format!(r#""{command_line}""#)). All four claims hold, and the enum has no deny_unknown_fields, so an older Codex ignores the new key rather than failing the manifest. test (windows) is green on this head, which is what exercises the cmd.exe quoting, the goto resolution, and the || exit /b 0 tail.
One finding, inline: the shim doesn't clear its own locals, so an inherited WT bypasses the PATH scan it exists to perform.
…ass the PATH scan setlocal scopes writes but inherits the caller's environment, and Codex hands each hook the session env snapshot, so a user's own WT arrives defined. The PATH scan then never runs: 'if not defined WT' is false on every iteration, 'if defined WT goto :run' fires, and the shim executes the inherited value — skipping the Windows Terminal rejection that block exists for. WT_SEEN had the mirror problem, picking the 'resolves to Windows Terminal' message when nothing was found.
worktrunk-bot
left a comment
There was a problem hiding this comment.
The inherited-WT finding from the last pass is addressed in c9a1721, and test_shim_ignores_an_inherited_wt is a real regression test for it — it fails on the pre-fix shim, and it stays honest about the runner's own PATH by pinning "the sentinel did not run" rather than "worktrunk ran".
Two things from this pass, both inline. The one worth a look before merge is the shim's binary resolution: where and cmd's bare-name lookup both reach the current directory, which is the repo the hook is running in — that is new surface relative to wt.sh, whose command -v is PATH-only. It is my own judgement that this wants a human rather than a repo rule; the deletion-surface hold in the review guidance doesn't cover it, since nothing here can delete anything. The other is the changelog entry.
Self-authored, so no approval either way. test (linux|macos|windows) and code-coverage were still in flight at this head; nothing had gone red.
`where git-wt.exe` searches the current directory before PATH, and cmd resolves the bare `git-wt.exe` that `:gitwt` stored the same way. A hook runs with the user's project as its current directory, so a `git-wt.exe` -- or a `wt.exe`, through the scan below it -- committed to a repo was what every prompt, permission request, stop, and session end executed. `wt.sh` has no such surface: `command -v` consults PATH only. Both lookups now use `where`'s `$PATH:` prefix, which searches only the directories that variable names, and `:run` executes the absolute path they return, so nothing re-resolves a bare name. `:gitwt` is gone with its bare-name store. `test_shim_ignores_a_binary_in_the_current_directory` plants decoys in the cwd and a real worktrunk on PATH; the decoys are not valid executables, so a version line pins that the shim resolved through PATH. Also folds in the review's changelog edit -- the entry cites its own PR and comes back under the 60-word ceiling -- and teaches the release skill to rename an existing `## Unreleased` heading rather than insert `## X.Y.Z` above it, which would ship the stale heading in the notes.
The new cwd test asserted the `--version` output carried CARGO_PKG_VERSION. CI's checkout embeds a bare commit hash instead (`wt ad1a14b`), so the assertion failed on the Windows leg even though the shim resolved correctly through PATH. Pin the `wt ` prefix, which is the part a decoy could never produce.
worktrunk-bot
left a comment
There was a problem hiding this comment.
test (windows) was red at fd89ee1b on this PR's own new test, and 9e7458d3 fixes it correctly — starts_with("wt ") is the pin test_mock_dispatch_ignores_degenerate_argv0 in tests/integration_tests/init.rs already uses for the same question. Recording why it failed, since the old assertion could never have passed here: version_str() in src/cli/mod.rs resolves option_env!("VERGEN_GIT_DESCRIBE") first and falls back to CARGO_PKG_VERSION only when the build script couldn't set it — the crates.io-package case, not CI.
That failed run is also the evidence the $PATH: prefix was still missing. The shim exited 0 and printed wt ad1a14b, so where "$PATH:git-wt.exe" resolved to the copy on the pinned PATH and not to the invalid git-wt.exe decoy in the cwd — had it taken the decoy, cmd could not have run it at all. The syntax no longer rests on the where documentation; it ran, and 4332 of 4333 tests passed on that leg with nothing else red.
One finding on this pass, inline: eight lines in the new test that restate a guarantee std::process::Command already gives.
Self-authored, so no approval either way. CI at 9e7458d3 is still in flight.
std::process::Command's environment map is case-insensitive on Windows (EnvKey compares with CompareStringOrdinal, bIgnoreCase = TRUE), so .env("PATH", ...) replaces an inherited `Path` rather than landing beside it. The vars_os scan restated that guarantee.
|
but don't we already require git bash? and so avoiding using bash seems unnecessary? (I thought I asked this before so apologies if this was answered somewhere else) |
|
We do require Git for Windows, and this PR doesn't relax that — We already treat those as two different claims elsewhere in the codebase: The bash-preserving alternative is real, and I'm happy to switch if you prefer its shape: Why the shim rather than locating Git BashTo keep Given that, resolving the The trade against it: On "I thought I asked this before" — I don't find it on this PR or #4007, and no other thread I can see covers it, so nothing was dropped; it just hadn't been asked yet. All checks are green at |
this seems reasonable, arguably simpler, but up to you if you've thought about it a lot |
Replaces the cmd.exe shim's own worktrunk resolution with the Git Bash resolution `find_git_bash` performs in src/shell_exec.rs, then hands off to the same `wt.sh` every other integration runs. Only the bare name `bash` was the problem under cmd.exe -- Git for Windows is a requirement either way -- so worktrunk's own resolution order now lives in one script rather than in two languages. `wt.sh` clears `WT` up front: on Windows every branch that assigns it can be skipped, and a hook is handed the caller's whole environment, so an inherited `WT` was what the final check accepted and ran.
worktrunk-bot
left a comment
There was a problem hiding this comment.
The switch to the bash-preserving shape reads well, and the shim tracks find_git_bash faithfully: same derive-from-git.exe primary, same Git\bin before Git\usr\bin, same two install fallbacks — with %ProgramFiles% where the Rust version hardcodes C:\Program Files, which is the better of the two.
One finding, inline: where is itself still spelled as a bare name, so cmd resolves it from the hook's current directory — the same surface the $PATH: prefix closes one level down, and the mechanism is the one this file's own comment states.
Two things I could not settle from here, both bearing on that line rather than arguing against it:
- Neither Windows test would go red if the
wherelookup broke. The runner's Git is atC:\Program Files\Git, so a lookup that returns nothing falls straight through to the%ProgramFiles%\Git\bin\bash.exefallback, which resolves — andtest_codex_windows_hook_commands_set_the_markerandtest_shim_ignores_a_git_in_the_current_directoryboth still pass. That is true of the line as it stands too: the evidence that$PATH:works is the earlier red run atfd89ee1b, not the suite. So whichever way the line ends up, the derive branch is unpinned, and a test that plants abash.exesomewhere the fallbacks can't reach would be what makes it observable. SessionEndkeeps"timeout": 3, and the chain under it on Windows is now cmd.exe →for /f's child cmd →where.exe→bash.exe→wt.exe, against Unix's/bin/sh→bash→wt. I have no measurement of that on a cold Windows box, and I could not establish from here what Codex does when a hook times out — if it is the "Hook failed" banner, that budget is worth a look.
Self-authored, so no approval either way. At 4041caa, gate, fast-checks, feature-check, pre-commit.ci and codecov/patch are green; test (linux) and the advisory legs are still running.
|
Switched to it — pushed as
One thing the switch turned up: All checks are green at What the Windows leg actually confirmedThe marker test pins PATH to the shape a default Git for Windows install produces —
Still unverified, unchanged from before: nothing here drives a real Codex session on Windows, so "Codex picks |
`where` is not a cmd built-in — it is `System32\where.exe` — so cmd resolves that bare name the way it resolves any other: current directory first, then PATH. The hook's current directory is the user's project, so a `where` committed to a repo chose the `git.exe` that chose the `bash.exe` every prompt, permission request, stop, and session end ran. That is the surface the `$PATH:` prefix closes one level down, still open on the command doing the closing. `test_shim_ignores_a_where_in_the_current_directory` reaches the derive branch specifically, which is what the existing Windows tests do not: an unrunnable decoy leaves the lookup empty and the shim falls through to `%ProgramFiles%\Git\bin\bash.exe`, which resolves on any Windows box, so the test would pass on the unscoped shim for the wrong reason. The decoy is a `.bat` naming a Git install whose `bin\bash.exe` exists, so an unscoped `where` leaves `BASH` set and unrunnable. A probe pins the premise separately, so the test cannot go green because the planted `where` was never the one cmd would have chosen. A spoofed `%SystemRoot%` degrades safely: `where.exe` is not found, the lookup is empty, and the shim falls through to the `%ProgramFiles%` install it already trusts.
…l defaults The two scoping tests see only the wrong bash: they go red because the shim picks a decoy. A lookup that finds nothing — a mis-quoted `for /f`, a `where` cmd cannot run — falls through to %ProgramFiles%\Git\bin\bash.exe, which resolves on any Windows box, so the derive branch's success path was unobserved. Pointing ProgramFiles and LOCALAPPDATA at an empty directory leaves :derive as the only route to a bash.
…eout ceiling SessionEnd's `timeout: 3` is Codex's ceiling for the event (SESSION_END_MAX_TIMEOUT_SEC in codex-rs/hooks/src/events/session_end.rs): omitting the key takes a 1s default, and a larger value is clamped with a warning. A hook that exceeds it is reported as an error, which is the same "Hook failed" banner #4007 is about.
worktrunk-bot
left a comment
There was a problem hiding this comment.
Both findings from the last pass are closed, and the way the second one closed also settles what I said I could not check from here.
test_shim_derives_bash_from_the_git_on_path points ProgramFiles and LOCALAPPDATA at an empty directory, leaving :derive as the only route to a bash — the test I named as missing last pass ("a test that plants a bash.exe somewhere the fallbacks can't reach would be what makes it observable"). It also answers the quoting question I could only reason about: test (windows) is green at 2405b8b, so '"%SystemRoot%\System32\where.exe" "$PATH:git.exe" 2^>nul' does survive the cmd /c "…" wrapping for /f applies. Had it not, the shim would have reached :missing and exited 1 with both fallbacks emptied.
One finding, and it is the PR description rather than the code: it predates the last two commits. Testing still opens "Four tests, all new" and lists four; there are six, and the two it omits are this increment's substance — test_shim_ignores_a_where_in_the_current_directory and test_shim_derives_bash_from_the_git_on_path. Solution likewise still describes the lookup as where "$PATH:git.exe" rather than the %SystemRoot%\System32\where.exe it is now. The "what this does not verify" block does not need touching: the two install-default fallbacks genuinely remain unexercised, since the new test empties them rather than reaching them.
Nothing else this pass — no code findings. One thing worth naming for whoever merges, since it is not a defect: this diff edits plugins/worktrunk/hooks/wt.sh, which the review guidance lists as shipped automation and which the Claude WorktreeRemove hook runs wt remove --foreground "$p" through. The WT="" line changes which binary that resolves to, strictly narrowing it — an inherited WT could previously be executed with those arguments, and now cannot.
Self-authored, so no approval either way. At 2405b8b every check is green except test (linux), still running.
Upstream claims the new CLAUDE.md prose asserts, re-checked
The added paragraph makes three claims about code outside this repo that a reader can't cheaply confirm. All hold:
SessionEnd's timeout ceiling.SESSION_END_DEFAULT_TIMEOUT_SEC = 1andSESSION_END_MAX_TIMEOUT_SEC = 3incodex-rs/hooks/src/events/session_end.rs;normalize_command_hookincodex-rs/hooks/src/engine/discovery.rspushes"clamping {} hook timeout to {max_timeout_sec}s in {}"for anything larger, then.clamp(1, max_timeout_sec). So "omitting the key takes the 1s default, and a larger value is clamped with a warning" is exact, andtimeout: 3really is the whole budget the longer Windows chain can be given.- The env vars
test_shim_derives_bash_from_the_git_on_pathrelies on reaching a real hook.WINDOWS_CORE_ENV_VARSincodex-rs/protocol/src/shell_environment.rslistsSYSTEMROOT,PROGRAMFILESandLOCALAPPDATA. Worth adding to the test's own note: theinherit = "core"filter is followed by a default exclude of*KEY*/*SECRET*/*TOKEN*, case-insensitive — none of the three match, so the premise survives that step too. commandWindowsreplacingcommand, verified on an earlier pass and unchanged.
Separately, the .gitattributes CRLF pin is load-bearing rather than decorative: codex plugin marketplace add max-sixty/worktrunk reaches clone_git_source in codex-rs/core-plugins/src/marketplace_add/install.rs, which shells out to git clone (and git sparse-checkout / git checkout on the sparse path). That is a checkout, so plugins/worktrunk/hooks/*.cmd text eol=crlf applies on the user's disk and the goto labels resolve.
worktrunk 0.77.0 Created-by: HarmonybrewBot Commit-by: HarmonybrewBot Merged-by: HarmonybrewBot Description: Created by `brew bump` --- Created with `brew bump-formula-pr`.<details> <summary>release notes</summary> <pre>## Release Notes ### Improved - **`wt list` sizes and aligns its columns to the terminal width**: one long branch name no longer sizes the Branch column for every row — it caps at 32 characters and elides, so a narrow terminal keeps its other columns instead of degenerating into a branch list. `Remote⇅` stops holding blank space open in a repo with no remote, and alignment now follows the value type. `--format=json` still carries the whole name. ([#3998](max-sixty/worktrunk#3998)) - **`wt list` names what it hid and marks detached worktrees**: the summary reads `hidden: Path, Commit` instead of counting columns, a detached worktree shows `⊘` rather than borrowing `⚑`, and a prunable row leaves its cells blank instead of showing loading dots that never resolve. ([#3998](max-sixty/worktrunk#3998)) - **`wt list --format=json` gains a `marker` field and stops reporting `detached` twice**: the branch marker set by `wt config state marker` is now readable without parsing it out of `symbols`, and `state` no longer reports a detached worktree as `branch_worktree_mismatch` — the sibling `detached` boolean carries it. ([#3998](max-sixty/worktrunk#3998)) - **`wt list --format=json` now defaults to schema 2**: callers get the envelope with repository metadata and orthogonal per-item facts without configuring `[list] json-schema`. `wt list statusline --format=json` follows the same key. Set `json-schema = 1` to retain the original bare-array format. (Breaking.) ([#4038](max-sixty/worktrunk#4038)) - **`wt config show` fails on a broken config**: it exits 1 on an unreadable or invalid config, an invalid `[list] columns`, or an invalid `approvals.toml`. Unknown keys and deprecations stay warnings and still exit 0. A new `APPROVALS` section counts project commands awaiting approval. (Breaking: it always exited 0 before.) ([#3999](max-sixty/worktrunk#3999)) - **Config migration output names its destination**: `wt config update --output <path>` writes the migration artifact to that file instead of applying it in place, and `--output=-` writes it to stdout. Output mode includes project config when run from a linked worktree. (Breaking: `--print` was removed.) ([#4021](max-sixty/worktrunk#4021)) - **Pi joins the agent integrations**: `wt config plugins pi install` writes an activity hook to `~/.omp/agent/hooks/pre/worktrunk.ts`, so Pi sessions show 🤖/💬 markers in `wt list` like Claude Code, Codex, OpenCode, and Gemini. `$PI_CONFIG_DIR`, `$PI_CODING_AGENT_DIR`, and named `$OMP_PROFILE`/`$PI_PROFILE` profiles are honored. [Docs](https://worktrunk.dev/claude-code/) ([#3594](max-sixty/worktrunk#3594), thanks @adity982, and @ortonomy for the request) - **`wt config plugins codex install` installs the plugin too**: it previously registered the marketplace and left you to run `/plugins` in Codex yourself. Uninstall follows for both Codex and Claude Code, removing the plugin and then the marketplace, so the two commands are inverses. (Breaking: Codex uninstall previously left an installed plugin alone; Claude uninstall now also removes the marketplace.) ([#4019](max-sixty/worktrunk#4019)) - **`wt step copy-ignored` says whether it reflinked or copied**: the summary now reads `(reflinked, no extra disk)`, `(full copy)`, or a partial count, so the same `29.5 GB` line distinguishes a free copy from one that actually wrote the bytes. `--format=json` gains `reflinked` and `written`; a `--dry-run` plan no longer reports `files`/`bytes`. ([#4025](max-sixty/worktrunk#4025)) - **`wt config update` removes keys its destination cannot hold**: migrating `[select]` or `[commit-generation]` drops keys the new section has no field for, reporting each one. Otherwise the key landed at a path the user never typed and warned on every command, with no way to clear it. ([#3994](max-sixty/worktrunk#3994)) ### Fixed - **A failed `wt switch pr:<n>` against a fork no longer deletes a branch it didn't create**: the rollback force-deleted the branch on any setup error, including the one case where the branch wasn't its own — a `pre-switch` hook or a concurrent session claiming the name in the window after the forge answered. Branch and worktree are now created in one `git worktree add -b`, which writes nothing when the name is taken. ([#3984](max-sixty/worktrunk#3984)) - **Codex on Windows: the activity hooks no longer fail on every event**: each hook led with a bare `bash`, which `cmd.exe` resolves to the WSL launcher rather than Git Bash, so every event raised a `Hook failed` banner. Hooks now run `wt.sh` through a shim that finds Git Bash by path. ([#4008](max-sixty/worktrunk#4008), fixes [#4007](max-sixty/worktrunk#4007), thanks @McNultyyy for reporting and diagnosing) - **Templates leave `branch` unset in a detached worktree**: aliases and hooks rendered `{{ branch }}` as the literal `HEAD`, which git resolves, so an unguarded template ran against the wrong ref — the reported case ended in `git push origin --delete HEAD`, which git refused. `base` and `target` follow. (Breaking: an unguarded `{{ branch }}` now errors.) ([#4010](max-sixty/worktrunk#4010), fixes [#4009](max-sixty/worktrunk#4009)) - **The OpenCode plugin loads under OpenCode 2**: OpenCode 2's loader dropped the old bare-function export silently, so activity markers stopped appearing. The plugin now exports one object carrying both the v2 `setup` and v1 `server` entry points, and spawns `wt` through `node:child_process` rather than the Bun shell. Re-run `wt config plugins opencode install` to pick it up; the one file covers OpenCode 2 and OpenCode 1.16 or later. ([#4018](max-sixty/worktrunk#4018), thanks @pragmaticivan for the request) - **`wt step commit --branch` builds its prompt from the worktree being committed**: prompt-building git plumbing ran in the invoking worktree, so with nothing staged there the generated message described none of the changes it committed. `wt step relocate --commit` had the same bug, and `--dry-run`/`--show-prompt` now preview the worktree `--branch` names. ([#3996](max-sixty/worktrunk#3996)) - **LLM commit messages keep diffs for quoted paths**: a file whose name git quotes in the `diff --git` header — any non-ASCII name under the default `core.quotePath` — had its whole diff section dropped from the prompt once the diff exceeded the 100 KB budget. ([#4041](max-sixty/worktrunk#4041)) - **An unparsable user config is reported, not a panic**: `wt config show --format=json` and `--full`, plus `wt step prune`, `relocate`, `eval`, and `for-each`, panicked on a debug build and printed a bare `✗ Command failed` on a release one. They now report `✗ Failed to load config` with the parser's caret diagram. ([#4002](max-sixty/worktrunk#4002)) - **`wt switch --execute` names the directory the program actually starts in**: the `Executing (--execute) @ …` header rendered the worktree the background hooks run in, not the program's own directory — which differs under `--no-cd` or a switch from a subdirectory. The path is omitted when the shell already stands there. ([#4043](max-sixty/worktrunk#4043), fixes [#4042](max-sixty/worktrunk#4042), thanks @yajo for reporting) - **`wt config plugins claude|codex uninstall` is safe to re-run**: removing an already-absent marketplace reported a failure, and a Claude uninstall that removed the plugin and then failed left the marketplace with no way to clear it. Both now finish a half-done uninstall and succeed when there is nothing left to remove. ([#4033](max-sixty/worktrunk#4033), [#4034](max-sixty/worktrunk#4034)) - **`wt config state marker` set and clear no-op outside a git repository**: they exited 1 with a git error, so an agent plugin running outside a repository printed one every turn (the hook's `|| true` kept the session going). ([#3981](max-sixty/worktrunk#3981), thanks @mahirhir) - **`wt list` produces output in a bare repo with no worktrees**: it printed nothing at all and exited 0. It now reports `○ No worktrees` with a hint, `--branches` lists the branches, and `--format json` emits the envelope with an empty `items`. ([#3992](max-sixty/worktrunk#3992)) - **`wt list` warns when it can't parse the project config** rather than ignoring it silently; the listing still succeeds. ([#3993](max-sixty/worktrunk#3993)) - **Relayed command output no longer prints ahead of the delayed-stream progress line**, and delayed-stream output routes through anstream so color is stripped consistently when redirected. ([#4037](max-sixty/worktrunk#4037), [#3995](max-sixty/worktrunk#3995)) - **A hook filter that matches nothing names the unconfigured source**, rather than reporting no match without saying which source was empty. ([#3997](max-sixty/worktrunk#3997)) ### Documentation - **`wt hook` says that `post-*` hooks from the two sources run concurrently**, so dependent commands belong in one source; `wt merge` records that `post-commit` can't run when the merge removes the worktree it anchors on. ([#4020](max-sixty/worktrunk#4020), [#4026](max-sixty/worktrunk#4026)) - **`wt step` help pages describe what the commands actually do**: `wt step prune` says it removes br See merge request: Harmonybrew/homebrew-core!18763
Problem
Codex resolves a hook
commandthrough the platform shell —/bin/sh -lcon Unix,cmd.exe /Con Windows (default_shell_command). All four Codex hooks in the plugin manifest lead with a barebash, and undercmd.exethat resolves through the Windows PATH toSystem32\bash.exe— the WSL launcher, not Git Bash. In a sandboxed session the launcher refuses to start (Access is denied. Error code: Bash/Service/CreateInstance/E_ACCESSDENIED), so every prompt, permission request, turn end, and session end raises aHook failedbanner (#4007).Solution
Git for Windows stays a requirement; only the bare name
bashis the problem, sincecmd.exeresolves it through PATH. Each hook now also carries Codex's per-handlercommandWindows, which replacescommandon Windows (command_windows.unwrap_or(command)incodex-rs/hooks/src/engine/discovery.rs). It calls a newcmd.exeshim,plugins/worktrunk/hooks/wt.cmd, that locatesbash.exeby path the wayfind_git_bashdoes insrc/shell_exec.rs— derived fromgit.exe's install directory, then the system-wide and per-user install defaults — and then runs the samehooks/wt.shthat Claude, Gemini, and Unix Codex already go through. Worktrunk's own binary resolution stays in that one script instead of being spelled a second time in cmd.Two details the shim inherits from the Rust resolver:
Git\bin\bash.exebeforeGit\usr\bin\bash.exe, because the former is the wrapper that sets up the MSYS environment for a caller outside Git Bash (which is what putsunamewithin reach ofwt.sh); and a PATH-scoped lookup, because an unscopedwheresearches the current directory first — for a hook that's the user's project, so agit.execommitted to a repo would otherwise choose the bash every event runs. The lookup is spelled"%SystemRoot%\System32\where.exe" "$PATH:git.exe"on both counts: the$PATH:prefix scopes what is searched, and the absolute path towhere.execloses the same surface one level up —whereisSystem32\where.exerather than a cmd built-in, so cmd resolves that bare name from the current directory too.wt.shnow clearsWTbefore its branches. On Windows all of them can be skipped (neithergit-wt.exenorwton PATH), and a hook is handed the caller's whole environment, so an inheritedWTwas what the finalcommand -v "$WT"check accepted and ran.The Windows commands brace the plugin root as
${PLUGIN_ROOT}because Codex substitutes only that form textually, before the shell runs; the unbraced$PLUGIN_ROOTthe Unix commands use survives to/bin/sh, andcmd.exewould pass it through literally. The tail is|| exit /b 0, the cmd.exe spelling of the Unix|| true: a marker is decoration, and a nonzero exit is what raises the banner..gitattributespinsplugins/worktrunk/hooks/*.cmdto a CRLF checkout, since cmd.exe resolves agotolabel by seeking through the file and can fail that search on an LF-only batch file.Two doc changes ride along.
plugins/worktrunk/CLAUDE.mdrecords why every hook carriescommandWindows, the shim's resolution order and the two lookup-scoping decisions, and whySessionEndkeepstimeout: 3(Codex's ceiling for that event, so the longer Windows chain has no more budget to ask for). And because the CHANGELOG entry opens an## Unreleasedsection,.claude/skills/release/SKILL.mdstep 9 now says to rename that heading at release time rather than insert a new one above it — otherwise the release ships a stale## Unreleased.Testing
Six tests, all new:
test_codex_hooks_carry_windows_commands(all platforms) — pins that every Codex command hook has acommandWindows, that it names neitherbashnor barewt, that it calls the shim, and that the twoPLUGIN_ROOTspellings stay on their respective sides. Written first: it failed on the manifest as shipped, with the exact hook command from the report.test_codex_windows_hook_commands_set_the_marker(Windows leg of CI) — runs the realcommandWindowsthe way Codex spawns it, reproducing both steps: the${PLUGIN_ROOT}substitution andcmd.exe /C "<command>"with the command line as a single quoted raw argument. It assertsUserPromptSubmitstores 🤖,Stopreplaces it with 💬,SessionEndclears it, and that a hook which cannot find worktrunk still exits 0. PATH is pinned to the shape a default Git for Windows install produces —Git\cmdand nothing else from the install — so the run covers cmd.exe's quote handling, the shim's search for bash,wt.shrunning under the bash it picks, and the emoji surviving both hops.test_wt_sh_ignores_an_inherited_wt(Windows leg) — pointsWTat a real worktrunk, spelled the way bash can run it, on a PATH wherewt.shfinds none itself, and pins that the inherited value is not what runs.test_shim_ignores_a_git_in_the_current_directory(Windows leg) — plants an unrunnablegit.exein the hook's current directory plus thebash.exethe shim would derive from it, puts a real Git and worktrunk on the pinned PATH, and asserts the shim prints a version line — which it could not do had it taken the decoy.test_shim_ignores_a_where_in_the_current_directory(Windows leg) — the same question one level up: awhere.batin the hook's current directory names a decoy Git install whosebin\bash.exeexists, so an unscopedwherewould reach the derive branch and leaveBASHset and unrunnable rather than falling through to a real install. A separate probe pins the premise — that the plantedwherereally does shadowSystem32\where.exe— so the test cannot go green because the decoy was never consulted.test_shim_derives_bash_from_the_git_on_path(Windows leg) — pointsProgramFilesandLOCALAPPDATAat an empty directory, which leaves the derive branch as the only route to a bash. The two tests above go red only when the shim picks the wrong bash; this one goes red when the lookup finds nothing, so it is what observes that"%SystemRoot%\System32\where.exe" "$PATH:git.exe"survives the quotingfor /fwraps it in.What this does not verify
Nothing here drives a real Codex session on Windows, so the end-to-end claim — that Codex selects
commandWindowsand that the banner stops — rests on readingcodex-rs/hooks/src/engine/{discovery,command_runner}.rsrather than on observation. What CI does exercise is the command string itself, executed the way that source says Codex executes it.The shim's two standard-install fallbacks (
%ProgramFiles%\Git,%LOCALAPPDATA%\Programs\Git) are unexercised by CI. Every test that reaches a bash takes the derive branch above them, and the one test that touches the fallbacks empties them to force that branch rather than to exercise them; they share the whole tail with it.Two adjacent things are deliberately left alone, as separate concerns: the Gemini hooks at the repo-root
hooks/hooks.jsonuse the same barebash(Gemini's Windows hook execution isn't established here), and Claude'shooks/hooks.jsonis unaffected because Claude Code runs hook commands through Git Bash on Windows.Closes #4007 — automated triage