Skip to content

fix(browse,design): resolve ~/.gstack via os.homedir() on Windows#1120

Open
scarson wants to merge 1 commit intogarrytan:mainfrom
scarson:fix/windows-homedir-fallback
Open

fix(browse,design): resolve ~/.gstack via os.homedir() on Windows#1120
scarson wants to merge 1 commit intogarrytan:mainfrom
scarson:fix/windows-homedir-fallback

Conversation

@scarson
Copy link
Copy Markdown

@scarson scarson commented Apr 21, 2026

Summary

On Windows, gstack's persistent state directory (~/.gstack/) lands at nonsense paths — chromium profile, sidebar session data, ngrok config, agent queue, worktree cache, OpenAI key file, browse state — whenever a user runs gstack binaries (browse.exe, pdf.exe, design.exe, sidebar agent) from any shell that isn't Git Bash. That covers cmd.exe, PowerShell, VS Code's integrated terminal when not explicitly configured for Git Bash, Windows Terminal default profile, and any IDE spawning subprocesses with its own env block.

Root cause: 31 sites in browse/src/ and design/ construct paths with process.env.HOME || <fallback>. On Windows, HOME is unset (Windows uses USERPROFILE), so the fallback kicks in. The fallbacks are all broken in different ways:

Pattern Site count Behavior on Windows when HOME unset
process.env.HOME || '/tmp' 20 path.join('/tmp', ...)\tmp\.gstack\... — literal path, doesn't exist
process.env.HOME || '' 8 path.join('', ...) → CWD-relative — silently mislocates
process.env.HOME! 1 Non-null assertion crashes at load time
process.env.HOME || "~" 1 Creates literal ~ directory under CWD; Node path APIs never expand ~
process.env.HOME || process.env.USERPROFILE || '/tmp' 1 Already platform-aware; simplified

Repro

Minimal Node script simulating an invocation from a native-Windows shell:

delete process.env.HOME;
console.log('HOME was unset:', process.env.HOME === undefined);
console.log('USERPROFILE:', process.env.USERPROFILE);

const path = require('path');
const os = require('os');

const oldFallback = path.join(process.env.HOME || '/tmp', '.gstack', 'sidebar-agent-queue.jsonl');
const newFix      = path.join(os.homedir(),                 '.gstack', 'sidebar-agent-queue.jsonl');

console.log('OLD (|| "/tmp"):', oldFallback);
console.log('NEW (os.homedir):', newFix);

Output on Windows 11:

HOME was unset: true
USERPROFILE: C:\Users\Sam

OLD (|| "/tmp"):  \tmp\.gstack\sidebar-agent-queue.jsonl
NEW (os.homedir): C:\Users\Sam\.gstack\sidebar-agent-queue.jsonl

The OLD path doesn't exist. Every fs.writeFileSync targeting ~/.gstack/ against the OLD path fails with ENOENT, usually caught and logged as a confusing "couldn't save state" warning, and the feature appears partially-working to the user — tokens don't persist across restarts, the chromium profile resets every launch, the sidebar session directory rotates and loses chat history.

Why this shipped unnoticed

  • Sam's clone (and most gstack Windows users') runs every command from Git Bash, which sets HOME by default. So HOME || '/tmp' short-circuits on HOME and the state lands correctly — until an IDE-spawned subprocess inherits a sanitized env without HOME, or the user runs browse.exe from PowerShell to see what happens.
  • Every .github/workflows/ job runs on ubuntu-latest or macos-latest. CI never touches the Windows branch of this fallback. fix(make-pdf): Windows .exe resolution for browse + pdftotext #1118's analysis of the make-pdf-gate matrix comment applies identically here.

The fix

Replace every variant with os.homedir(). Per Node docs:

os.homedir() returns the string path of the current user's home directory.
On POSIX, it uses the $HOME environment variable if defined. Otherwise it uses the effective UID to look up the user's home directory.
On Windows, it uses the USERPROFILE environment variable if defined. Otherwise it uses the path to the profile directory of the current user.

Strictly better than every pattern it replaces, on every platform. Specifically:

  • POSIX CI test isolation that sets process.env.HOME = tmpHome (see browse/test/security-review-flow.test.ts:30) keeps working — os.homedir() still reads HOME on POSIX.
  • The one site already using HOME || USERPROFILE || '/tmp' collapses cleanly to os.homedir().
  • The HOME! non-null assertion in design/prototype.ts:13 stops being a load-time crash risk.

Files changed

File Sites What lived there
browse/src/cli.ts 8 ngrok env, ngrok config search path (3 paths), gstack dir, chromium profile (x2), agent queue
browse/src/server.ts 13 sessions dir, browse binary lookup, global home, worktree dir, agent queue, cancel dir, chromium profile (x2), welcome path, ngrok env, ngrok config search (3 paths), ngrok env (second use site)
browse/src/browser-manager.ts 5 extension path, gstack dir, chromium profile (x2), app icon path
browse/src/sidebar-agent.ts 3 queue, cancel dir, browse.json
design/prototype.ts 1 process.env.HOME!os.homedir() (crash-on-unset bug)
design/src/auth.ts 1 || "~"os.homedir() (literal-tilde bug)

Added import * as os from 'os' (or import os from "os" for the design files, matching each file's existing default-import style) to the 6 files that didn't already import it.

Test plan

  • Full bun test browse/test/ make-pdf/test/ on Windows — 163 pass, 10 fail. Same 163/10 on clean upstream main (stashed + re-ran for exact comparison), identical failing test names. All 10 failures are pre-existing and unrelated to this PR's surface:
    • batch.test.ts + compare-board.test.tsbeforeEach/afterEach hook timeouts (5s limit; appears to be a Windows-specific test-harness issue, not the code under test)
    • bun-polyfill.test.ts x4 — Bun.serve/spawn/spawnSync/sleep assertions, all expecting "OK" or "HAS_STOP" and receiving "", suggesting a pre-existing Bun polyfill subshell stdout-capture issue on this platform
    • sidebar-integration.test.ts + sidebar-agent-roundtrip.test.tsagent_done transitions to idle, queues message when agent is processing, kill adds error entry — same failures on clean main, unrelated to path construction
  • Empirical proof with HOME unset on Windows 11 (see Repro above) — os.homedir() produces the correct C:\Users\Sam\.gstack\... path where the OLD fallback produced \tmp\.gstack\...
  • Ubuntu/macOS CI pass — relying on the existing matrix. POSIX branch is semantically identical (os.homedir() falls through to $HOME on POSIX, same as the old short-circuit), so no regression expected. Test that overrides process.env.HOME in security-review-flow.test.ts keeps passing because os.homedir() on POSIX honors HOME overrides.
  • End-to-end browse.exe start from PowerShell/cmd.exe verified — not run. The unit-test pass rate + empirical before/after is sufficient confidence given the mechanical nature of the change.

What this PR doesn't do

Commits

  • d3a6b5efix(browse,design): resolve ~/.gstack via os.homedir() not $HOME || /tmp

Single commit. The change is mechanical and atomic across the 6 files — no value in splitting further.

🤖 Generated with Claude Code

31 sites across 6 files constructed paths for `~/.gstack/` state, chromium
profile, ngrok env, sidebar session dirs, worktrees, agent queue, openai
config, etc., using one of these patterns:

  path.join(process.env.HOME || '/tmp', '.gstack', ...)          (20 sites)
  path.join(process.env.HOME || '',     '.claude/skills/...', ...) (8 sites)
  path.join(process.env.HOME!,          '.gstack/openai.json')    (1 site — non-null assertion)
  path.join(process.env.HOME || "~",    '.gstack', 'openai.json') (1 site — literal "~" never expands)
  process.env.HOME || process.env.USERPROFILE || '/tmp'           (1 site — already platform-aware)

On Windows, `process.env.HOME` is NOT set by default. Windows uses
`USERPROFILE` for the same purpose. Git Bash happens to set HOME, so
gstack state currently lands correctly when a user runs commands from a
Git Bash shell — which is the only supported dev path, since `./setup`
is a bash script. But:

- Compiled binaries (`browse.exe`, etc.) spawn detached subprocesses
  (the server, the sidebar agent, chromium) with the environment of the
  calling shell. When a user runs `browse.exe` from cmd.exe or PowerShell
  (or from an IDE that doesn't inherit Git Bash's env), HOME is unset.
- `path.join('/tmp', '.gstack', 'x')` resolves to `\tmp\.gstack\x` on
  Windows — literal directory that doesn't exist.
- `path.join('', '.claude/skills/...', 'x')` resolves to a relative path
  from CWD — silent mislocation.
- `process.env.HOME!` crashes with a non-null assertion at load time.
- `path.join("~", ...)` creates a literal `~` dir under CWD; Node path
  APIs never expand `~`.

Fix: replace all five variants with `os.homedir()`. Per Node docs, `os.homedir()`:

  - on POSIX: returns `$HOME` if set, else consults `getpwuid(geteuid())`
  - on Windows: returns `$USERPROFILE` if set, else calls the Win32 API

Strictly better than every variant above on every platform. Test-isolation
patterns that set `process.env.HOME = tmpDir` (e.g. the one in
`browse/test/security-review-flow.test.ts:30`) keep working on POSIX CI
because `os.homedir()` reads HOME there.

Added `import * as os from 'os'` / `import os from "os"` to the 6 files
that didn't already have it, matching each file's existing import style.

Empirical proof on Windows 11 (cmd.exe-equivalent env with HOME unset):

  OLD (|| "/tmp"):  \tmp\.gstack\sidebar-agent-queue.jsonl
  NEW (os.homedir): C:\Users\Sam\.gstack\sidebar-agent-queue.jsonl

Test suite: 163 pass / 10 fail on this branch. Identical 163/10 on clean
upstream main with the same test selection (confirmed by stash + rerun).
The 10 failures are all pre-existing (batch.test.ts hook timeout,
bun-polyfill Bun.serve/spawn/sleep assertions, a few sidebar-integration
tests that hit a pre-existing beforeEach timeout) and don't touch the
paths this PR modifies. Zero regressions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
scarson added a commit to scarson/gstack that referenced this pull request Apr 21, 2026
# Conflicts:
#	browse/src/browser-manager.ts
#	browse/src/server.ts
@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