Skip to content

fix(discovery): resolve Windows commands through PATHEXT, not the bare name - #34

Merged
Harold Hunt (huntharo) merged 1 commit into
mainfrom
claude/quizzical-rosalind-02bbbe
Aug 18, 2026
Merged

fix(discovery): resolve Windows commands through PATHEXT, not the bare name#34
Harold Hunt (huntharo) merged 1 commit into
mainfrom
claude/quizzical-rosalind-02bbbe

Conversation

@huntharo

Copy link
Copy Markdown
Contributor

The bug

buildPathCommandNames returned [command, ...extensions] — the bare, extensionless name first — and resolvePathCommand walks that list per PATH directory and returns the first hit.

npm installs three shims side by side: codex (an sh script, for Git Bash), codex.cmd, and codex.ps1. So in any npm or nvm-windows bin directory the scan stopped on the sh script and never reached codex.cmd. The tool is present and working; discovery reported it missing.

Measured on a Windows guest with a working Codex 0.146.0 install at C:\nvm4w\nodejs\:

where codex
C:\nvm4w\nodejs\codex        <- sh script, unusable on Windows, chosen by the scan
C:\nvm4w\nodejs\codex.cmd    <- the real entry point, never reached

.PS1 is not in the default PATHEXT, so the .ps1 was never the problem — the bare name was.

This is not Codex-specific. discoverCommands is generic, so every consumer resolving a tool (git, gh, …) on a Windows npm-shim layout has the same defect.

What changed

Ordering (buildPathCommandNames) — on win32 the bare name is no longer a candidate at all. I dropped it rather than merely demoting it below the extensions: CreateProcess appends .exe to an extensionless name, so that file is unlaunchable from child_process no matter where it sits in the list, and keeping it as a fallback would still reproduce the bug across directories — the scan's outer loop is per-directory, so an extensionless codex in an earlier PATH entry would still beat a codex.cmd in a later one.

One exception: a command carrying a non-PATHEXT extension (tool.ps1) keeps the verbatim name, tried last. The caller named a specific file; dropping it entirely would make it undiscoverable. A name already ending in a PATHEXT extension still resolves to itself.

PATHEXT is now read case-insensitively (it was env.PATHEXT exact, while PATH was already case-insensitive). The sibling agent-acp enumerator (executableCommandNames) already does exactly this — extensions only, no bare name — so this brings the two packages in line.

POSIX is untouched — same single-element [command] return, asserted in tests.

pathIsExecutable — fixed here, not deferred. It used access(X_OK), which on Windows has no execute bit to consult and degrades to F_OK: it answered true for a README, so candidate.executable on win32 was asserting nothing. It now judges by PATHEXT there (the rule CreateProcess and cmd.exe actually apply) and confirms existence separately; POSIX still asks the filesystem. New optional second argument PathIsExecutableOptions { env, platform } defaulting to process.env / process.platform, so existing one-arg calls keep compiling. Discovery still ORs this with "the version probe actually ran", so a candidate proven to execute is unaffected.

Verification

The end-to-end assertion (a directory holding both codex and codex.cmd resolves to the .cmd) can only run on a Windows host — path.win32.join produces backslash paths that never match a POSIX temp file, which is why the pre-existing Windows tests here are already runIf(isWindows). So it's added there and buildPathCommandNames is exported module-locally (not from index.ts, matching the normalizeTimeoutMs precedent) for host-independent ordering coverage.

I confirmed the new tests actually catch the old behavior by temporarily restoring the buggy code: 5 failures (4 ordering + 1 executability). The POSIX assertions passed in both states.

Full suite: agent-core 20, agent-transport 28, agent-chat-react 22, agent-acp 147, codex-discovery 64 passing.

Pre-existing failures, not from this change

4 tests in packages/codex-discovery/test/codex-discovery.test.ts fail on my machine. I verified they fail identically on the clean tree with these changes stashed. They're host contamination: this Mac has a real codex 0.146.1 at /usr/local/bin, which discovery scans beyond the test's PATH, so the "nothing installed" and "only an old binary" tests find it. Worth a separate fix — flagging so it isn't attributed here.

Reviewer notes

  • Changeset included, minor. It flags that consumers pinned to ^0.1.6 need to widen the range — the package is already at 0.2.0, so PwrAgent won't pick this up without that bump.
  • Backward compatible: no exported name changed meaning off Windows, and nothing was removed.
  • Downstream: fix(desktop): launch Windows Codex through its .cmd shim PwrAgent#1734 carries a compensating module plus coordinator special-casing for this. The other half of that compensation — the hardcoded 2s --version probe timeout and timeouts being indistinguishable from "not installed" — already landed upstream in dbc7298 / d589cf7, so with this both halves are covered and most of that downstream module can go away.

🤖 Generated with Claude Code

…e name

`buildPathCommandNames` put the extensionless command name FIRST in the list
`resolvePathCommand` walks per PATH directory, and the scan returns its first
hit. npm installs three shims side by side -- `codex` (an sh script, for Git
Bash), `codex.cmd`, and `codex.ps1` -- so in any npm or nvm-windows bin
directory the scan stopped on the sh script and never reached `codex.cmd`.
Measured against a working Codex 0.146.0 install at `C:\nvm4w\nodejs\`: `where
codex` lists the sh script first, discovery picked it, and a present, working
tool was reported missing. (`.PS1` is not in the default PATHEXT, so the `.ps1`
was never the problem -- the bare name was.)

This is not Codex-specific: `discoverCommands` is generic, so every consumer
resolving a tool (`git`, `gh`, ...) on a Windows npm-shim layout hit the same
defect.

On win32 the bare name is no longer a candidate at all, rather than merely
demoted: `CreateProcess` appends `.exe` to an extensionless name, so such a
file cannot be launched by `child_process` wherever it sits in the list, and
keeping it as a fallback would still reproduce the bug across directories (the
scan's outer loop is per-directory, so an extensionless `codex` in an earlier
PATH entry would still beat a `codex.cmd` in a later one). Only a command
carrying some other, non-PATHEXT extension (`tool.ps1`) keeps the verbatim
name, tried last, so a caller who named a specific file can still discover it.
PATH and PATHEXT are now both read case-insensitively on win32. This matches
what the sibling `agent-acp` enumerator already does. POSIX resolution is
unchanged -- the bare name is correct and the only option there.

`pathIsExecutable` is fixed in the same pass rather than left as a known
weakness. It used `access(X_OK)`, which on Windows has no execute bit to
consult and degrades to `F_OK` -- it answered true for a README, so
`candidate.executable` on win32 asserted nothing. It now judges by PATHEXT
there (the rule `CreateProcess` and `cmd.exe` actually apply) and confirms
existence separately. It takes a new optional second argument,
`PathIsExecutableOptions` (`env`, `platform`), defaulting to `process.env` /
`process.platform`, so existing one-argument calls keep compiling. Discovery
still ORs this with "the version probe actually ran", so a candidate proven to
execute is unaffected.

The end-to-end assertion (a directory holding both `codex` and `codex.cmd`
resolves to the `.cmd`) can only run on a Windows host -- `path.win32.join`
produces backslash paths that never match a POSIX temp file, which is why the
pre-existing Windows tests are already `runIf(isWindows)`. So it is added there
AND `buildPathCommandNames` is exported module-locally (not from `index.ts`,
matching the `normalizeTimeoutMs` precedent) for host-independent ordering
coverage. Both new suites were confirmed to fail against the old
implementation; the POSIX assertions pass in both states.
@huntharo
Harold Hunt (huntharo) force-pushed the claude/quizzical-rosalind-02bbbe branch from 9ec9fdf to 2091170 Compare August 18, 2026 03:52
@huntharo
Harold Hunt (huntharo) merged commit f3f8e96 into main Aug 18, 2026
2 checks passed
@huntharo
Harold Hunt (huntharo) deleted the claude/quizzical-rosalind-02bbbe branch August 18, 2026 03:53
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.

1 participant