Skip to content

fix(make-pdf): Windows .exe resolution for browse + pdftotext#1118

Open
scarson wants to merge 3 commits intogarrytan:mainfrom
scarson:fix/windows-binary-resolution
Open

fix(make-pdf): Windows .exe resolution for browse + pdftotext#1118
scarson wants to merge 3 commits intogarrytan:mainfrom
scarson:fix/windows-binary-resolution

Conversation

@scarson
Copy link
Copy Markdown

@scarson scarson commented Apr 21, 2026

Summary

On Windows, make-pdf generate fails with browse binary not found even after a successful ./setup. The resolution logic in make-pdf/src/browseClient.ts probes bare paths (browse, $selfDir/../browse/dist/browse, ~/.claude/skills/gstack/browse/dist/browse), but bun build --compile --outfile browse/dist/browse emits browse/dist/browse.exe on Windows, so every probe misses.

Repro (Windows, bun 1.3.11, gstack v1.5.1.0, Node v25+):

$ ~/.claude/skills/gstack/make-pdf/dist/pdf.exe generate in.md out.pdf
Reading markdown (1ms)
Rendering HTML (135ms) — 8504 words
Opening tab...which: no browse in (<PATH>)
$P: browse resolve exited 127: browse binary not found.

Workaround the user has today: export BROWSE_BIN=/c/.../browse/dist/browse.exe. Fix in this PR: the resolver itself probes .exe on win32, so the workaround is no longer needed.

Root cause

Two independent issues compound:

1. fs.constants.X_OK is degraded to existence-checking on Windows. Per the Node.js docs on fs.accessSync:

On Windows, the file system accessibility checks can behave differently. For example, changing visibility using chmod does not update read and write permissions ... Only the presence of the read-only attribute is reflected.

So fs.accessSync('/path/to/browse', X_OK) on Windows checks whether a file literally named browse (no extension) exists. When bun emits browse.exe, the check returns false. The local isExecutable() helper inherits this semantic and returns false for every candidate.

2. bun --compile --outfile appends .exe on Windows unconditionally. The build command in package.json targets browse/dist/browse / make-pdf/dist/pdf / design/dist/design:

bun build --compile browse/src/cli.ts --outfile browse/dist/browse

On Windows, bun silently appends .exe (the PE format requires it for CreateProcess to locate the entrypoint). Confirmed on a fresh Windows checkout:

$ ls ~/.claude/skills/gstack/{browse,make-pdf,design}/dist/*.exe
browse/dist/browse.exe
browse/dist/find-browse.exe
make-pdf/dist/pdf.exe
design/dist/design.exe

There are no files named browse, pdf, or design in those directories — only their .exe variants.

3. The which browse PATH fallback doesn't help native Windows. which ships with Git Bash (MSYS2) but not with cmd.exe or PowerShell. A user running the compiled pdf.exe from PowerShell doesn't have which in PATH, so execFileSync("which", ...) throws ENOENT, the catch block swallows it, and we fall through to the browse binary not found error.

Why this shipped unnoticed

.github/workflows/make-pdf-gate.yml:25:

matrix:
  os: [ubuntu-latest, macos-latest]
  # Windows is tolerant-mode — Xpdf / Poppler-Windows extraction
  # differs enough from the Linux/macOS baseline that the strict
  # exact-diff gate is unreliable. Enable once the normalized
  # comparator proves tolerant enough (Codex round 2 #18).

No gstack workflow runs on Windows — not make-pdf-gate, not evals, not ci-image. This PR doesn't enable Windows CI (the tolerance-mode question the comment flags still needs a separate call), but it is a prerequisite — as-is, binary resolution would ERR out before pdftotext comparisons even started. A pre-existing test in browseClient.test.ts that hardcoded /bin/sh would also have broken Windows CI immediately; this PR makes that test cross-platform.

Scope

Three sites in the repo currently probe executable paths with fs.accessSync(p, X_OK):

File Symbol Affected? In scope?
make-pdf/src/browseClient.ts resolveBrowseBin Yes — primary bug Yes
make-pdf/src/pdftotext.ts resolvePdftotext Yes — same root cause; smaller blast radius (only affects bun test on Windows, not runtime renders) Yes
browse/src/security.ts findTelemetryBinary No — the telemetry binary is a bash script (#!/usr/bin/env bash), not a compiled .exe. Windows has a different blocker there (shebang scripts cannot be CreateProcess-ed directly). Out of scope — different bug, different fix

The fix

A file-local findExecutable(base) helper in each affected module. On win32, after the bare-path probe fails, it probes .exe, .cmd, .bat suffixes. On POSIX, isExecutable(base) is load-bearing and the Windows branch never runs, so Linux/macOS behavior is bit-identical to the old code.

export function findExecutable(base: string): string | null {
  if (isExecutable(base)) return base;
  if (process.platform === "win32") {
    for (const ext of [".exe", ".cmd", ".bat"]) {
      const withExt = base + ext;
      if (isExecutable(withExt)) return withExt;
    }
  }
  return null;
}

Exported from browseClient.ts (it is useful beyond internal resolution — any future site that probes an executable should route through it). Duplicated (not shared) in pdftotext.ts to match the existing isExecutable duplication pattern across the two modules.

Secondary change: the PATH fallback uses where on win32, which on POSIX. where ships in C:\Windows\System32\ on every Windows install, so execFileSync("where", ...) works from cmd.exe, PowerShell, AND Git Bash.

Tertiary change: pdftotext's macCandidates is renamed posixCandidates (it was always POSIX-only — /opt/homebrew/bin, /usr/local/bin, /usr/bin). No Windows candidates added — Poppler on Windows scatters across Scoop, Chocolatey, oschwartz10612/poppler-windows releases, and portable zips; guessing causes false positives. Users set PDFTOTEXT_BIN instead, which now works. The not-found error picks up a Windows install hint:

To install:
  macOS:    brew install poppler
  Ubuntu:   sudo apt-get install poppler-utils
  Fedora:   sudo dnf install poppler-utils
  Windows:  scoop install poppler  (or download from
            https://github.com/oschwartz10612/poppler-windows)

Test plan

  • bun test make-pdf/test/browseClient.test.ts make-pdf/test/pdftotext.test.ts on win32 — 21 pass, 0 fail (including 5 new Windows-specific assertions that early-return on POSIX)
  • Full bun test make-pdf/test/ on win32 — 80 pass, 1 skip (e2e test needs built binary), 0 fail
  • End-to-end: unset BROWSE_BIN && bun run make-pdf/src/cli.ts generate /tmp/in.md /tmp/out.pdf on win32 produces a valid 31KB PDF using the patched resolveBrowseBin against the pre-existing browse.exe
  • Pre-existing resolveBrowseBin > honors BROWSE_BIN when it points at a real executable made cross-platform (was hardcoded /bin/sh, now uses cmd.exe on Windows). This would have been a Windows-CI blocker.
  • Ubuntu/macOS CI pass — relying on the make-pdf-gate workflow once this PR is opened. POSIX branch is unchanged; isExecutable(base) is the first probe and short-circuits before the Windows-only extension loop.
  • /pair-agent / OAuth flows not exercised — unchanged, no touchpoint with this PR's surface area.

Commits

  • 705996bfix(make-pdf): resolve bun-compiled .exe binaries on Windows (browseClient + its test)
  • f04bbd1fix(make-pdf): apply .exe extension probe to pdftotext resolver (pdftotext + its test)

Atomic: each commit is independently reviewable and leaves the test suite green on POSIX. Commit 1 is the user-visible fix (what the failing render hit); commit 2 is the matching fix for the same root cause in a less-exercised code path.

What this PR does not do

  • Enable Windows CI. The make-pdf-gate.yml matrix comment about pdftotext extraction tolerance on Windows is a separate question that still needs a call.
  • Fix findTelemetryBinary in browse/src/security.ts. Different bug (shebang-script invocation on Windows), different fix, separate PR if it ever becomes user-facing.
  • Touch the sidebar agent's fs.accessSync call. That one checks a directory for reachability, not executability — unrelated.

🤖 Generated with Claude Code

scarson and others added 2 commits April 21, 2026 01:21
`resolveBrowseBin()` probed bare paths (`browse`, `$selfDir/../browse/dist/browse`,
`~/.claude/skills/gstack/browse/dist/browse`) with `fs.accessSync(path, X_OK)`.
On Windows, bun's `--compile --outfile browse/dist/browse` lands the artifact
at `browse/dist/browse.exe`, and `fs.constants.X_OK` is degraded to an
existence check (Node docs: "On Windows, the file system accessibility checks
can behave differently. For example, changing visibility using chmod does
not update read and write permissions... Only the presence of the read-only
attribute is reflected."). So every probe returned false even though the
binary was present, and the tool errored with `browse binary not found`
unless the user set `BROWSE_BIN=/c/.../dist/browse.exe` explicitly.

Fix: a new exported helper `findExecutable(base)` returns the resolved path,
probing `.exe`/`.cmd`/`.bat` suffixes on win32. Every call site in
`resolveBrowseBin` now routes through it. The POSIX path is semantically
unchanged — `isExecutable(base)` runs first and returns the base as-is when
it exists, so Linux/macOS callers see no behavior change.

Also: the final PATH fallback ran `which browse`, which exists under Git Bash
but not in cmd.exe or PowerShell. On win32 we now shell out to `where`
(always in `System32`, so it resolves without Git Bash in PATH). POSIX still
uses `which`.

Regression tests cover the three moving parts: the `findExecutable` helper
directly (baseline, missing path, `.exe` probe, `.cmd`/`.bat` probe), and
the `resolveBrowseBin` env-override path on win32 with a base path that
needs `.exe`. The pre-existing "honors BROWSE_BIN" test hardcoded `/bin/sh`
and always failed on win32 — made cross-platform with `cmd.exe` as the
Windows probe target, so Windows CI (currently commented out in
.github/workflows/make-pdf-gate.yml) won't trip on this when enabled.

Verified end-to-end on win32: with BROWSE_BIN unset, `bun run make-pdf/src/cli.ts
generate in.md out.pdf` now resolves `browse.exe` via the global path probe
and produces a valid 31KB PDF.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same root cause as the previous commit, smaller blast radius. The pdftotext
wrapper only runs in the copy-paste CI gate and unit tests (never in
production renders), so Windows users don't hit it at runtime — but anyone
running `bun test make-pdf/` on Windows with poppler installed via Scoop or
Chocolatey and `PDFTOTEXT_BIN=C:\tools\poppler\bin\pdftotext` set would
have fallen through to `PdftotextUnavailableError` because the on-disk
artifact is `pdftotext.exe`.

Changes mirror `browseClient.ts`:

1. File-local `findExecutable(base)` helper that probes `.exe`/`.cmd`/`.bat`
   on win32. Duplicated rather than shared with browseClient to keep the
   existing module-independence convention (both files already duplicate
   `isExecutable`).

2. `which pdftotext` PATH lookup becomes `where pdftotext` on win32, so
   users running tests from cmd.exe or PowerShell (no Git Bash in PATH)
   still get PATH resolution.

3. `macCandidates` renamed `posixCandidates` — it was always a POSIX-only
   set (`/opt/homebrew/bin`, `/usr/local/bin`, `/usr/bin`). No Windows
   candidates added: Poppler on Windows scatters across Scoop, Chocolatey,
   oschwartz10612/poppler-windows releases, and portable zips — guessing
   causes false positives. Users with poppler outside PATH set the env var,
   which now works.

4. Error message picks up a Windows install hint:
   `scoop install poppler` + the oschwartz10612/poppler-windows link.

Regression test: env-override probe with a base path that needs `.exe`,
guarded to win32 with early-return on POSIX. Empty `.exe` is sufficient
because `describeBinary()` catches the non-zero exit from `bin -v` and
records `version: "unknown"` — the test asserts only on the resolved path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adopt the platform-aware error-message UX from @BkashJEE's independent
concurrent fix (garrytan#1094). On Windows, `export FOO=...` is the wrong
syntax — users want `setx FOO "..."` for a persistent environment
variable in cmd.exe/PowerShell, or `$env:FOO = "..."` for session-local
PowerShell. `setx` is the closest one-liner equivalent and matches what
Bikash proposed.

Also adds `Platform: <process.platform>` to both error messages — tiny
but useful for bug reports, especially given both fix sites (browse and
pdftotext) have subtle platform-specific behavior.

Credit: @BkashJEE for the `setx` hint shape in garrytan#1094.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@scarson
Copy link
Copy Markdown
Author

scarson commented Apr 21, 2026

Heads-up for reviewers: @BkashJEE got to this diagnosis first in #1094 (opened 2026-04-20, one day before this PR). Same core root cause — Node's fs.accessSync(X_OK) degrades to existence-check on Windows, bun --compile --outfile emits .exe, which isn't available outside Git Bash so where is needed. Different shape on the fix, overlapping coverage.

What this PR adds beyond #1094:

  • pdftotext.ts fix — same root cause in a second binary resolver (resolvePdftotext). Same findExecutable + where-vs-which treatment. fix(make-pdf): resolve browse binary on Windows without BROWSE_BIN #1094 doesn't touch this file.
  • Tests — 5 new assertions covering findExecutable helper + resolveBrowseBin win32 env-override path + a regression test for resolvePdftotext. fix(make-pdf): resolve browse binary on Windows without BROWSE_BIN #1094 explicitly notes "No platform-conditional test added."
  • Non-.exe extensions — probes .cmd and .bat in addition to .exe, in case future gstack installs ship batch wrappers.
  • Pre-existing test blocker fixresolveBrowseBin > honors BROWSE_BIN when it points at a real executable hardcoded /bin/sh; made cross-platform (uses cmd.exe on Windows) so Windows CI is actually runnable whenever the TODO at make-pdf-gate.yml:25 gets addressed.

Just adopted the setx BROWSE_BIN "..." error-message phrasing from #1094 as commit 7b13d19 — credit noted in the commit body.

Genuinely fine either way on consolidation. If @garrytan prefers to land #1094 as the primary (narrower diff, landed first), happy to rebase the pdftotext + tests + cross-platform test fix as a follow-on PR on top. This matches the #1024 pattern where two concurrent fixes converged and the consolidating PR credited both contributors.

@scarson
Copy link
Copy Markdown
Author

scarson commented Apr 21, 2026

For context: opened #1122 today ("Boiling the Windows CI Lake") proposing a Phase-1 Windows smoke CI that would have caught this class of bug at PR time. This PR is cited in the RFC's coverage table as one of the five recent examples that motivated it — not asking for anything on this PR, it can merge on its own merits. Cross-linking because the "Why this shipped unnoticed" paragraph above reads differently with a systemic answer in flight.

Also a companion issue at #1123 for higher-level discussion if the PR's review thread gets crowded.

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