fix(browse): wrap telemetry bash script with bash.exe on Windows#1119
Open
scarson wants to merge 1 commit intogarrytan:mainfrom
Open
fix(browse): wrap telemetry bash script with bash.exe on Windows#1119scarson wants to merge 1 commit intogarrytan:mainfrom
scarson wants to merge 1 commit intogarrytan:mainfrom
Conversation
`reportAttemptTelemetry()` in browse/src/security.ts spawns
`gstack-telemetry-log` directly via Node's `child_process.spawn(bin, args)`.
On Windows, that fails with `ENOENT` / `-4058` (UV_ENOENT) even when the
script exists on disk, because `gstack-telemetry-log` is a shebang-scripted
bash file (`#!/usr/bin/env bash`) and Windows `CreateProcess` cannot launch
shebangs — it treats the file as a PE image and bails when the header
doesn't match. The `child.on('error', ...)` handler swallows the error per
the fire-and-forget semantics, so the failure is invisible: attack-attempt
telemetry drops on the floor on every Windows install.
Empirical reproduction on Windows 11, Node v25, bun 1.3.11:
spawn('/path/to/gstack-telemetry-log', [...])
→ spawn error: ENOENT - spawn /path/to/gstack-telemetry-log ENOENT
→ exit: -4058
spawn('bash', ['/path/to/gstack-telemetry-log', ...])
→ exit: 0 | stdout: telemetry received: ...
Fix: extract `buildTelemetrySpawnCommand(bin, args)` as a pure function of
(platform, bin, args) that returns `{ cmd: 'bash', cmdArgs: [bin, ...args] }`
on win32 and `{ cmd: bin, cmdArgs: args }` on POSIX. `reportAttemptTelemetry`
consumes the tuple and hands it to `spawn` unchanged. The POSIX path is
bit-identical to the old code.
Windows dev boxes running gstack invariably ship Git Bash (the installer
puts `bash.exe` on PATH), so in practice the wrapped invocation works
without further setup. If `bash` is missing, `spawn` fires an 'error' event
and the same existing swallow path kicks in — local `attempts.jsonl` keeps
the audit trail intact either way. No new failure modes introduced.
Why this shipped unnoticed: no gstack workflow runs on Windows — the
matrix in every CI file is `ubuntu-latest` and/or `macos-latest` only.
On both POSIX platforms the shebang is honored by `execve` and the
spawn succeeds, so the bug never triggered in CI.
Why `shell: true` is NOT an acceptable alternative fix: `record.urlDomain`
is extracted from user-browsed URLs, and `record.payloadHash`/`record.layer`/
`record.verdict` also flow through this call. Passing `shell: true` would
route those through `cmd.exe` arg parsing and open a shell-injection path
the moment a URL contains a quote or backslash. Explicit `spawn('bash',
[...])` keeps every arg in argv, so escaping stays honest.
Tests: three new assertions covering the POSIX pass-through path, the
win32 bash wrap, and arg-array immutability. Guarded by
`if (process.platform === 'win32') return;` / its inverse, so POSIX CI
exercises the platform-relevant branch automatically and the Windows
branch is covered whenever a contributor runs `bun test` on Windows.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This was referenced Apr 21, 2026
Author
|
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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
reportAttemptTelemetry()inbrowse/src/security.tscallsspawn(bin, args)wherebinis thegstack-telemetry-logbash script. On Windows this fails silently withENOENTon every attack-attempt telemetry event, because WindowsCreateProcesscannot launch shebang-scripted files. The error is swallowed by the fire-and-forgetchild.on('error', ...)handler, so the failure is invisible — telemetry drops on the floor, no log, no surfaced warning. The localattempts.jsonlwrite still lands, so there's a local audit trail, but the tier-aware upload path (community / anonymous / off, handled inside the bash binary) never runs on any Windows install.Follow-up to #1118 (
fix(make-pdf): Windows .exe resolution...): same family of bug — Node's Windows subprocess layer can't resolve the script layouts gstack actually ships — in a new call site.Empirical reproduction
Windows 11, Node v25, bun 1.3.11. Stand-in script stands in for
gstack-telemetry-log(same shebang shape):-4058isUV_ENOENT/ERROR_FILE_NOT_FOUND— libuv trying to execute the file as a PE image and getting rejected. The file is present on disk and readable; Windows just can't dispatch on the shebang.With the fix applied (
spawn('bash', [script, ...args])):Root cause
Windows does not implement kernel-level shebang handling the way POSIX
execvedoes. When Node'sspawn()hands an absolute path (with no extension) toCreateProcess, Windows tries to load the file as a PE image. Forgstack-telemetry-log— a 10-KB file starting with#!/usr/bin/env bash\n# gstack-telemetry-log ...— the PE header check fails and CreateProcess returnsERROR_FILE_NOT_FOUND(counterintuitively — the file exists, but "no loadable image" is the closest documented error).The resolution helper
findTelemetryBinary()(L316) already does the right thing:fs.accessSync(bin, X_OK)returns true because on WindowsX_OKis degraded to existence-checking (same Node-docs quirk cited in #1118). So the binary is "found" and then spawned, and then the spawn silently fails.Scope
Audited every
spawn/execFile/Bun.spawn/Bun.$call site in the repo TypeScript sources looking for this class of bug:gstack-telemetry-log(bash)browse/src/security.ts:346scripts/generate-preamble-*.tsbashexplicitly alreadybrowse/test/helpers/eval-store.ts:26spawnSync('bash', ['-c', ...]), already wrappedspawn('claude', ...)x 3 sitesbrowse/src/{security-classifier,sidebar-agent}.tsclaude.exeon PATH, which Node resolves natively. A subset of users (npm-global install) may haveclaude.cmd, which recent Node versions block spawning withoutshell: true(CVE-2024-27980 hardening). Verified empirically on this box withclaude.exe— works. npm-install variant deserves its own follow-up with properexecFilesemantics since args contain user-controlled prompt text.This PR fixes the one site in the audit that's unconditionally broken for every Windows user.
Why this shipped unnoticed
Same story as #1118.
.github/workflows/runs every job onubuntu-latestormacos-latest. On both POSIX platforms,execvereads the shebang and delegates tobash, so the spawn succeeds. Windows is not in any matrix, andmake-pdf-gate.yml:25explicitly comments that Windows-CI is on the TODO list.The fix
Extract a pure function
buildTelemetrySpawnCommand(bin, args)that returns the platform-correct{ cmd, cmdArgs }tuple:reportAttemptTelemetryconsumes the tuple and hands it tospawnunchanged:The POSIX path is bit-identical to the old code. On Windows,
spawn('bash', [bin, ...args])works unconditionally for Git Bash installs (Git Bash putsbash.exeon the system PATH by default — that's how every Windows developer ends up with gstack installed in the first place, since./setupis a bash script). Ifbashis genuinely missing,spawnfires the same'error'event that's already swallowed — no new failure mode.Alternatives considered and rejected
spawn(bin, args, { shell: true }). Routes throughcmd.exeargument parsing.record.urlDomainflows from user-browsed URLs;record.payloadHash/record.layer/record.verdictall feed in from the security classifier's analysis of user-controlled input. A URL containing"or&would be an injection vector. Rejected..ps1or.cmdwrapper alongsidegstack-telemetry-log. Requires changes outsidebrowse/src/and infrastructure to keep the Windows wrapper in sync. Worth doing eventually but out of scope for this PR.Test plan
bun test browse/test/security.test.tson win32 — 32 pass, 0 fail (includes 3 new assertions: POSIX pass-through, win32 bash wrap, arg-array immutability)ENOENT→exit 0(stdout captured above)process.platform !== 'win32', and the POSIX pass-through test early-returns onwin32, so both platforms exercise the branch they should{ cmd: bin, cmdArgs: args }.Commits
d7098a3—fix(browse): wrap telemetry bash script with bash.exe on Windows(source + test)Single commit; the change is small and cohesive. Source and test go together.
What this PR doesn't do
spawn('claude', ...)sites insidebar-agent.tsandsecurity-classifier.ts. Conditional on install method (standalone.exeworks; npm.cmdmay fail on Node ≥20.12 per CVE-2024-27980 hardening). Args contain user prompt text, soshell: trueisn't safe — the right fix isexecFile(which does auto-shell.cmdon Windows without exposing args to shell parsing) or explicit extension resolution. Separate PR once scope is validated.🤖 Generated with Claude Code