From d3a6b5e77877d0e684d9f99844e41e637b7e8da3 Mon Sep 17 00:00:00 2001 From: Samuel Carson Date: Tue, 21 Apr 2026 02:12:53 -0500 Subject: [PATCH] fix(browse,design): resolve ~/.gstack via os.homedir() not $HOME || /tmp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- browse/src/browser-manager.ts | 11 ++++++----- browse/src/cli.ts | 17 +++++++++-------- browse/src/server.ts | 29 +++++++++++++++-------------- browse/src/sidebar-agent.ts | 7 ++++--- design/prototype.ts | 3 ++- design/src/auth.ts | 3 ++- 6 files changed, 38 insertions(+), 32 deletions(-) diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index 2885d1cce5..a791ef691c 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -15,6 +15,7 @@ * restores state. Falls back to clean slate on any failure. */ +import * as os from 'os'; import { chromium, type Browser, type BrowserContext, type BrowserContextOptions, type Page, type Locator, type Cookie } from 'playwright'; import { addConsoleEntry, addNetworkEntry, addDialogEntry, networkBuffer, type DialogEntry } from './buffers'; import { validateNavigationUrl } from './url-validation'; @@ -139,7 +140,7 @@ export class BrowserManager { // Relative to this source file (dev mode: browse/src/ -> ../../extension) path.resolve(__dirname, '..', '..', 'extension'), // Global gstack install - path.join(process.env.HOME || '', '.claude', 'skills', 'gstack', 'extension'), + path.join(os.homedir(), '.claude', 'skills', 'gstack', 'extension'), // Git repo root (detected via BROWSE_STATE_FILE location) (() => { const stateFile = process.env.BROWSE_STATE_FILE || ''; @@ -266,7 +267,7 @@ export class BrowserManager { if (authToken) { const fs = require('fs'); const path = require('path'); - const gstackDir = path.join(process.env.HOME || '/tmp', '.gstack'); + const gstackDir = path.join(os.homedir(), '.gstack'); fs.mkdirSync(gstackDir, { recursive: true }); const authFile = path.join(gstackDir, '.auth.json'); try { @@ -283,7 +284,7 @@ export class BrowserManager { // so we use Playwright's bundled Chromium which reliably loads extensions. const fs = require('fs'); const path = require('path'); - const userDataDir = path.join(process.env.HOME || '/tmp', '.gstack', 'chromium-profile'); + const userDataDir = path.join(os.homedir(), '.gstack', 'chromium-profile'); fs.mkdirSync(userDataDir, { recursive: true }); // Support custom Chromium binary via GSTACK_CHROMIUM_PATH env var. @@ -310,7 +311,7 @@ export class BrowserManager { // Replace Chromium's Dock icon with ours (Chromium's process owns the Dock icon) const iconCandidates = [ path.join(__dirname, '..', '..', 'scripts', 'app', 'icon.icns'), // repo dev mode - path.join(process.env.HOME || '', '.claude', 'skills', 'gstack', 'scripts', 'app', 'icon.icns'), // global install + path.join(os.homedir(), '.claude', 'skills', 'gstack', 'scripts', 'app', 'icon.icns'), // global install ]; const iconSrc = iconCandidates.find(p => fs.existsSync(p)); if (iconSrc) { @@ -1158,7 +1159,7 @@ export class BrowserManager { console.log('[browse] Handoff: extension not found — headed mode without side panel'); } - const userDataDir = path.join(process.env.HOME || '/tmp', '.gstack', 'chromium-profile'); + const userDataDir = path.join(os.homedir(), '.gstack', 'chromium-profile'); fs.mkdirSync(userDataDir, { recursive: true }); newContext = await chromium.launchPersistentContext(userDataDir, { diff --git a/browse/src/cli.ts b/browse/src/cli.ts index 30ab7555b7..58dced9c92 100644 --- a/browse/src/cli.ts +++ b/browse/src/cli.ts @@ -10,6 +10,7 @@ */ import * as fs from 'fs'; +import * as os from 'os'; import * as path from 'path'; import { safeUnlink, safeUnlinkQuiet, safeKill, isProcessAlive } from './error-handling'; import { resolveConfig, ensureStateDir, readVersionHash } from './config'; @@ -471,7 +472,7 @@ async function sendCommand(state: ServerState, command: string, args: string[], /** Check if ngrok is installed and authenticated (native config or gstack env). */ function isNgrokAvailable(): boolean { // Check gstack's own ngrok env - const ngrokEnvPath = path.join(process.env.HOME || '/tmp', '.gstack', 'ngrok.env'); + const ngrokEnvPath = path.join(os.homedir(), '.gstack', 'ngrok.env'); if (fs.existsSync(ngrokEnvPath)) return true; // Check NGROK_AUTHTOKEN env var @@ -479,9 +480,9 @@ function isNgrokAvailable(): boolean { // Check ngrok's native config (macOS + Linux) const ngrokConfigs = [ - path.join(process.env.HOME || '/tmp', 'Library', 'Application Support', 'ngrok', 'ngrok.yml'), - path.join(process.env.HOME || '/tmp', '.config', 'ngrok', 'ngrok.yml'), - path.join(process.env.HOME || '/tmp', '.ngrok2', 'ngrok.yml'), + path.join(os.homedir(), 'Library', 'Application Support', 'ngrok', 'ngrok.yml'), + path.join(os.homedir(), '.config', 'ngrok', 'ngrok.yml'), + path.join(os.homedir(), '.ngrok2', 'ngrok.yml'), ]; for (const conf of ngrokConfigs) { try { @@ -720,7 +721,7 @@ async function handlePairAgent(state: ServerState, args: string[]): Promise { // Check project-local designs first, then global const slug = process.env.GSTACK_SLUG || 'unknown'; - const homeDir = process.env.HOME || process.env.USERPROFILE || '/tmp'; + const homeDir = os.homedir(); const projectWelcome = `${homeDir}/.gstack/projects/${slug}/designs/welcome-page-20260331/finalized.html`; if (fs.existsSync(projectWelcome)) return projectWelcome; // Fallback: built-in welcome page from gstack install @@ -1681,7 +1682,7 @@ async function start() { // Read ngrok authtoken: env var > ~/.gstack/ngrok.env > ngrok native config let authtoken = process.env.NGROK_AUTHTOKEN; if (!authtoken) { - const ngrokEnvPath = path.join(process.env.HOME || '', '.gstack', 'ngrok.env'); + const ngrokEnvPath = path.join(os.homedir(), '.gstack', 'ngrok.env'); if (fs.existsSync(ngrokEnvPath)) { const envContent = fs.readFileSync(ngrokEnvPath, 'utf-8'); const match = envContent.match(/^NGROK_AUTHTOKEN=(.+)$/m); @@ -1691,9 +1692,9 @@ async function start() { if (!authtoken) { // Check ngrok's native config files const ngrokConfigs = [ - path.join(process.env.HOME || '', 'Library', 'Application Support', 'ngrok', 'ngrok.yml'), - path.join(process.env.HOME || '', '.config', 'ngrok', 'ngrok.yml'), - path.join(process.env.HOME || '', '.ngrok2', 'ngrok.yml'), + path.join(os.homedir(), 'Library', 'Application Support', 'ngrok', 'ngrok.yml'), + path.join(os.homedir(), '.config', 'ngrok', 'ngrok.yml'), + path.join(os.homedir(), '.ngrok2', 'ngrok.yml'), ]; for (const conf of ngrokConfigs) { try { @@ -2505,7 +2506,7 @@ async function start() { // Read ngrok authtoken from env or config file let authtoken = process.env.NGROK_AUTHTOKEN; if (!authtoken) { - const ngrokEnvPath = path.join(process.env.HOME || '', '.gstack', 'ngrok.env'); + const ngrokEnvPath = path.join(os.homedir(), '.gstack', 'ngrok.env'); if (fs.existsSync(ngrokEnvPath)) { const envContent = fs.readFileSync(ngrokEnvPath, 'utf-8'); const match = envContent.match(/^NGROK_AUTHTOKEN=(.+)$/m); diff --git a/browse/src/sidebar-agent.ts b/browse/src/sidebar-agent.ts index 9b7447c073..601fa39d06 100644 --- a/browse/src/sidebar-agent.ts +++ b/browse/src/sidebar-agent.ts @@ -11,6 +11,7 @@ import { spawn } from 'child_process'; import * as fs from 'fs'; +import * as os from 'os'; import * as path from 'path'; import { safeUnlink } from './error-handling'; import { @@ -26,14 +27,14 @@ import { type ToolCallInput, } from './security-classifier'; -const QUEUE = process.env.SIDEBAR_QUEUE_PATH || path.join(process.env.HOME || '/tmp', '.gstack', 'sidebar-agent-queue.jsonl'); +const QUEUE = process.env.SIDEBAR_QUEUE_PATH || path.join(os.homedir(), '.gstack', 'sidebar-agent-queue.jsonl'); const KILL_FILE = path.join(path.dirname(QUEUE), 'sidebar-agent-kill'); const SERVER_PORT = parseInt(process.env.BROWSE_SERVER_PORT || '34567', 10); const SERVER_URL = `http://127.0.0.1:${SERVER_PORT}`; const POLL_MS = 200; // 200ms poll — keeps time-to-first-token low const B = process.env.BROWSE_BIN || path.resolve(__dirname, '../../.claude/skills/gstack/browse/dist/browse'); -const CANCEL_DIR = path.join(process.env.HOME || '/tmp', '.gstack'); +const CANCEL_DIR = path.join(os.homedir(), '.gstack'); function cancelFileForTab(tabId: number): string { return path.join(CANCEL_DIR, `sidebar-agent-cancel-${tabId}`); } @@ -129,7 +130,7 @@ async function refreshToken(): Promise { // Read token from state file (same-user, mode 0o600) instead of /health try { const stateFile = process.env.BROWSE_STATE_FILE || - path.join(process.env.HOME || '/tmp', '.gstack', 'browse.json'); + path.join(os.homedir(), '.gstack', 'browse.json'); const data = JSON.parse(fs.readFileSync(stateFile, 'utf-8')); authToken = data.token || null; return authToken; diff --git a/design/prototype.ts b/design/prototype.ts index 74b9ec497b..da8db11808 100644 --- a/design/prototype.ts +++ b/design/prototype.ts @@ -7,10 +7,11 @@ */ import fs from "fs"; +import os from "os"; import path from "path"; const API_KEY = process.env.OPENAI_API_KEY - || JSON.parse(fs.readFileSync(path.join(process.env.HOME!, ".gstack/openai.json"), "utf-8")).api_key; + || JSON.parse(fs.readFileSync(path.join(os.homedir(), ".gstack/openai.json"), "utf-8")).api_key; if (!API_KEY) { console.error("No API key found. Set OPENAI_API_KEY or save to ~/.gstack/openai.json"); diff --git a/design/src/auth.ts b/design/src/auth.ts index a6bdc0cb43..9efad26ed1 100644 --- a/design/src/auth.ts +++ b/design/src/auth.ts @@ -8,9 +8,10 @@ */ import fs from "fs"; +import os from "os"; import path from "path"; -const CONFIG_PATH = path.join(process.env.HOME || "~", ".gstack", "openai.json"); +const CONFIG_PATH = path.join(os.homedir(), ".gstack", "openai.json"); export function resolveApiKey(): string | null { // 1. Check ~/.gstack/openai.json