Skip to content

Commit d3a6b5e

Browse files
scarsonclaude
andcommitted
fix(browse,design): resolve ~/.gstack via os.homedir() not $HOME || /tmp
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>
1 parent e23ff28 commit d3a6b5e

6 files changed

Lines changed: 38 additions & 32 deletions

File tree

browse/src/browser-manager.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
* restores state. Falls back to clean slate on any failure.
1616
*/
1717

18+
import * as os from 'os';
1819
import { chromium, type Browser, type BrowserContext, type BrowserContextOptions, type Page, type Locator, type Cookie } from 'playwright';
1920
import { addConsoleEntry, addNetworkEntry, addDialogEntry, networkBuffer, type DialogEntry } from './buffers';
2021
import { validateNavigationUrl } from './url-validation';
@@ -139,7 +140,7 @@ export class BrowserManager {
139140
// Relative to this source file (dev mode: browse/src/ -> ../../extension)
140141
path.resolve(__dirname, '..', '..', 'extension'),
141142
// Global gstack install
142-
path.join(process.env.HOME || '', '.claude', 'skills', 'gstack', 'extension'),
143+
path.join(os.homedir(), '.claude', 'skills', 'gstack', 'extension'),
143144
// Git repo root (detected via BROWSE_STATE_FILE location)
144145
(() => {
145146
const stateFile = process.env.BROWSE_STATE_FILE || '';
@@ -266,7 +267,7 @@ export class BrowserManager {
266267
if (authToken) {
267268
const fs = require('fs');
268269
const path = require('path');
269-
const gstackDir = path.join(process.env.HOME || '/tmp', '.gstack');
270+
const gstackDir = path.join(os.homedir(), '.gstack');
270271
fs.mkdirSync(gstackDir, { recursive: true });
271272
const authFile = path.join(gstackDir, '.auth.json');
272273
try {
@@ -283,7 +284,7 @@ export class BrowserManager {
283284
// so we use Playwright's bundled Chromium which reliably loads extensions.
284285
const fs = require('fs');
285286
const path = require('path');
286-
const userDataDir = path.join(process.env.HOME || '/tmp', '.gstack', 'chromium-profile');
287+
const userDataDir = path.join(os.homedir(), '.gstack', 'chromium-profile');
287288
fs.mkdirSync(userDataDir, { recursive: true });
288289

289290
// Support custom Chromium binary via GSTACK_CHROMIUM_PATH env var.
@@ -310,7 +311,7 @@ export class BrowserManager {
310311
// Replace Chromium's Dock icon with ours (Chromium's process owns the Dock icon)
311312
const iconCandidates = [
312313
path.join(__dirname, '..', '..', 'scripts', 'app', 'icon.icns'), // repo dev mode
313-
path.join(process.env.HOME || '', '.claude', 'skills', 'gstack', 'scripts', 'app', 'icon.icns'), // global install
314+
path.join(os.homedir(), '.claude', 'skills', 'gstack', 'scripts', 'app', 'icon.icns'), // global install
314315
];
315316
const iconSrc = iconCandidates.find(p => fs.existsSync(p));
316317
if (iconSrc) {
@@ -1158,7 +1159,7 @@ export class BrowserManager {
11581159
console.log('[browse] Handoff: extension not found — headed mode without side panel');
11591160
}
11601161

1161-
const userDataDir = path.join(process.env.HOME || '/tmp', '.gstack', 'chromium-profile');
1162+
const userDataDir = path.join(os.homedir(), '.gstack', 'chromium-profile');
11621163
fs.mkdirSync(userDataDir, { recursive: true });
11631164

11641165
newContext = await chromium.launchPersistentContext(userDataDir, {

browse/src/cli.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
*/
1111

1212
import * as fs from 'fs';
13+
import * as os from 'os';
1314
import * as path from 'path';
1415
import { safeUnlink, safeUnlinkQuiet, safeKill, isProcessAlive } from './error-handling';
1516
import { resolveConfig, ensureStateDir, readVersionHash } from './config';
@@ -471,17 +472,17 @@ async function sendCommand(state: ServerState, command: string, args: string[],
471472
/** Check if ngrok is installed and authenticated (native config or gstack env). */
472473
function isNgrokAvailable(): boolean {
473474
// Check gstack's own ngrok env
474-
const ngrokEnvPath = path.join(process.env.HOME || '/tmp', '.gstack', 'ngrok.env');
475+
const ngrokEnvPath = path.join(os.homedir(), '.gstack', 'ngrok.env');
475476
if (fs.existsSync(ngrokEnvPath)) return true;
476477

477478
// Check NGROK_AUTHTOKEN env var
478479
if (process.env.NGROK_AUTHTOKEN) return true;
479480

480481
// Check ngrok's native config (macOS + Linux)
481482
const ngrokConfigs = [
482-
path.join(process.env.HOME || '/tmp', 'Library', 'Application Support', 'ngrok', 'ngrok.yml'),
483-
path.join(process.env.HOME || '/tmp', '.config', 'ngrok', 'ngrok.yml'),
484-
path.join(process.env.HOME || '/tmp', '.ngrok2', 'ngrok.yml'),
483+
path.join(os.homedir(), 'Library', 'Application Support', 'ngrok', 'ngrok.yml'),
484+
path.join(os.homedir(), '.config', 'ngrok', 'ngrok.yml'),
485+
path.join(os.homedir(), '.ngrok2', 'ngrok.yml'),
485486
];
486487
for (const conf of ngrokConfigs) {
487488
try {
@@ -720,7 +721,7 @@ async function handlePairAgent(state: ServerState, args: string[]): Promise<void
720721
// Fallback to convention-based path
721722
}
722723

723-
const configDir = path.join(process.env.HOME || '/tmp', globalRoot);
724+
const configDir = path.join(os.homedir(), globalRoot);
724725
fs.mkdirSync(configDir, { recursive: true });
725726
const configFile = path.join(configDir, 'browse-remote.json');
726727
const configData = {
@@ -828,7 +829,7 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
828829
// Kill orphaned Chromium processes that may still hold the profile lock.
829830
// The server PID is the Bun process; Chromium is a child that can outlive it
830831
// if the server is killed abruptly (SIGKILL, crash, manual rm of state file).
831-
const profileDir = path.join(process.env.HOME || '/tmp', '.gstack', 'chromium-profile');
832+
const profileDir = path.join(os.homedir(), '.gstack', 'chromium-profile');
832833
try {
833834
const singletonLock = path.join(profileDir, 'SingletonLock');
834835
const lockTarget = fs.readlinkSync(singletonLock); // e.g. "hostname-12345"
@@ -893,7 +894,7 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
893894
throw new Error(`sidebar-agent.ts not found at ${agentScript}`);
894895
}
895896
// Clear old agent queue
896-
const agentQueue = path.join(process.env.HOME || '/tmp', '.gstack', 'sidebar-agent-queue.jsonl');
897+
const agentQueue = path.join(os.homedir(), '.gstack', 'sidebar-agent-queue.jsonl');
897898
try {
898899
fs.mkdirSync(path.dirname(agentQueue), { recursive: true, mode: 0o700 });
899900
fs.writeFileSync(agentQueue, '', { mode: 0o600 });
@@ -978,7 +979,7 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
978979
}
979980
}
980981
// Clean profile locks and state file
981-
const profileDir = path.join(process.env.HOME || '/tmp', '.gstack', 'chromium-profile');
982+
const profileDir = path.join(os.homedir(), '.gstack', 'chromium-profile');
982983
for (const lockFile of ['SingletonLock', 'SingletonSocket', 'SingletonCookie']) {
983984
safeUnlinkQuiet(path.join(profileDir, lockFile));
984985
}

browse/src/server.ts

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
* Port: random 10000-60000 (or BROWSE_PORT env for debug override)
1414
*/
1515

16+
import * as os from 'os';
1617
import { BrowserManager } from './browser-manager';
1718
import { handleReadCommand } from './read-commands';
1819
import { handleWriteCommand } from './write-commands';
@@ -185,7 +186,7 @@ interface SidebarSession {
185186
lastActiveAt: string;
186187
}
187188

188-
const SESSIONS_DIR = path.join(process.env.HOME || '/tmp', '.gstack', 'sidebar-sessions');
189+
const SESSIONS_DIR = path.join(os.homedir(), '.gstack', 'sidebar-sessions');
189190
const AGENT_TIMEOUT_MS = 300_000; // 5 minutes — multi-page tasks need time
190191
const MAX_QUEUE = 5;
191192

@@ -234,7 +235,7 @@ function findBrowseBin(): string {
234235
const candidates = [
235236
path.resolve(__dirname, '..', 'dist', 'browse'),
236237
path.resolve(__dirname, '..', '..', '.claude', 'skills', 'gstack', 'browse', 'dist', 'browse'),
237-
path.join(process.env.HOME || '', '.claude', 'skills', 'gstack', 'browse', 'dist', 'browse'),
238+
path.join(os.homedir(), '.claude', 'skills', 'gstack', 'browse', 'dist', 'browse'),
238239
];
239240
for (const c of candidates) {
240241
try { if (fs.existsSync(c)) return c; } catch (err: any) {
@@ -247,7 +248,7 @@ function findBrowseBin(): string {
247248
const BROWSE_BIN = findBrowseBin();
248249

249250
function findClaudeBin(): string | null {
250-
const home = process.env.HOME || '';
251+
const home = os.homedir();
251252
const candidates = [
252253
// Conductor app bundled binary (not a symlink — works reliably)
253254
path.join(home, 'Library', 'Application Support', 'com.conductor.app', 'bin', 'claude'),
@@ -381,7 +382,7 @@ function createWorktree(sessionId: string): string | null {
381382
if (gitCheck.exitCode !== 0) return null;
382383
const repoRoot = gitCheck.stdout.toString().trim();
383384

384-
const worktreeDir = path.join(process.env.HOME || '/tmp', '.gstack', 'worktrees', sessionId.slice(0, 8));
385+
const worktreeDir = path.join(os.homedir(), '.gstack', 'worktrees', sessionId.slice(0, 8));
385386

386387
// Clean up if dir exists from prior crash
387388
if (fs.existsSync(worktreeDir)) {
@@ -632,7 +633,7 @@ function spawnClaude(userMessage: string, extensionUrl?: string | null, forTabId
632633
// fails with ENOENT on everything, including /bin/bash). Instead,
633634
// write the command to a queue file that the sidebar-agent process
634635
// (running as non-compiled bun) picks up and spawns claude.
635-
const agentQueue = process.env.SIDEBAR_QUEUE_PATH || path.join(process.env.HOME || '/tmp', '.gstack', 'sidebar-agent-queue.jsonl');
636+
const agentQueue = process.env.SIDEBAR_QUEUE_PATH || path.join(os.homedir(), '.gstack', 'sidebar-agent-queue.jsonl');
636637
const gstackDir = path.dirname(agentQueue);
637638
const entry = JSON.stringify({
638639
ts: new Date().toISOString(),
@@ -676,7 +677,7 @@ function killAgent(targetTabId?: number | null): void {
676677
// Using per-tab files prevents race conditions where one agent's cancel
677678
// signal is consumed by a different tab's agent in concurrent mode.
678679
// When targetTabId is provided, only that tab's agent is cancelled.
679-
const cancelDir = path.join(process.env.HOME || '/tmp', '.gstack');
680+
const cancelDir = path.join(os.homedir(), '.gstack');
680681
const tabId = targetTabId ?? agentTabId ?? 0;
681682
const cancelFile = path.join(cancelDir, `sidebar-agent-cancel-${tabId}`);
682683
try {
@@ -1304,7 +1305,7 @@ async function shutdown(exitCode: number = 0) {
13041305
await browserManager.close();
13051306

13061307
// Clean up Chromium profile locks (prevent SingletonLock on next launch)
1307-
const profileDir = path.join(process.env.HOME || '/tmp', '.gstack', 'chromium-profile');
1308+
const profileDir = path.join(os.homedir(), '.gstack', 'chromium-profile');
13081309
for (const lockFile of ['SingletonLock', 'SingletonSocket', 'SingletonCookie']) {
13091310
safeUnlinkQuiet(path.join(profileDir, lockFile));
13101311
}
@@ -1367,7 +1368,7 @@ function emergencyCleanup() {
13671368
console.error('[browse] Emergency: failed to save session:', err.message);
13681369
}
13691370
// Clean Chromium profile locks
1370-
const profileDir = path.join(process.env.HOME || '/tmp', '.gstack', 'chromium-profile');
1371+
const profileDir = path.join(os.homedir(), '.gstack', 'chromium-profile');
13711372
for (const lockFile of ['SingletonLock', 'SingletonSocket', 'SingletonCookie']) {
13721373
safeUnlinkQuiet(path.join(profileDir, lockFile));
13731374
}
@@ -1423,7 +1424,7 @@ async function start() {
14231424
const welcomePath = (() => {
14241425
// Check project-local designs first, then global
14251426
const slug = process.env.GSTACK_SLUG || 'unknown';
1426-
const homeDir = process.env.HOME || process.env.USERPROFILE || '/tmp';
1427+
const homeDir = os.homedir();
14271428
const projectWelcome = `${homeDir}/.gstack/projects/${slug}/designs/welcome-page-20260331/finalized.html`;
14281429
if (fs.existsSync(projectWelcome)) return projectWelcome;
14291430
// Fallback: built-in welcome page from gstack install
@@ -1681,7 +1682,7 @@ async function start() {
16811682
// Read ngrok authtoken: env var > ~/.gstack/ngrok.env > ngrok native config
16821683
let authtoken = process.env.NGROK_AUTHTOKEN;
16831684
if (!authtoken) {
1684-
const ngrokEnvPath = path.join(process.env.HOME || '', '.gstack', 'ngrok.env');
1685+
const ngrokEnvPath = path.join(os.homedir(), '.gstack', 'ngrok.env');
16851686
if (fs.existsSync(ngrokEnvPath)) {
16861687
const envContent = fs.readFileSync(ngrokEnvPath, 'utf-8');
16871688
const match = envContent.match(/^NGROK_AUTHTOKEN=(.+)$/m);
@@ -1691,9 +1692,9 @@ async function start() {
16911692
if (!authtoken) {
16921693
// Check ngrok's native config files
16931694
const ngrokConfigs = [
1694-
path.join(process.env.HOME || '', 'Library', 'Application Support', 'ngrok', 'ngrok.yml'),
1695-
path.join(process.env.HOME || '', '.config', 'ngrok', 'ngrok.yml'),
1696-
path.join(process.env.HOME || '', '.ngrok2', 'ngrok.yml'),
1695+
path.join(os.homedir(), 'Library', 'Application Support', 'ngrok', 'ngrok.yml'),
1696+
path.join(os.homedir(), '.config', 'ngrok', 'ngrok.yml'),
1697+
path.join(os.homedir(), '.ngrok2', 'ngrok.yml'),
16971698
];
16981699
for (const conf of ngrokConfigs) {
16991700
try {
@@ -2505,7 +2506,7 @@ async function start() {
25052506
// Read ngrok authtoken from env or config file
25062507
let authtoken = process.env.NGROK_AUTHTOKEN;
25072508
if (!authtoken) {
2508-
const ngrokEnvPath = path.join(process.env.HOME || '', '.gstack', 'ngrok.env');
2509+
const ngrokEnvPath = path.join(os.homedir(), '.gstack', 'ngrok.env');
25092510
if (fs.existsSync(ngrokEnvPath)) {
25102511
const envContent = fs.readFileSync(ngrokEnvPath, 'utf-8');
25112512
const match = envContent.match(/^NGROK_AUTHTOKEN=(.+)$/m);

browse/src/sidebar-agent.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
import { spawn } from 'child_process';
1313
import * as fs from 'fs';
14+
import * as os from 'os';
1415
import * as path from 'path';
1516
import { safeUnlink } from './error-handling';
1617
import {
@@ -26,14 +27,14 @@ import {
2627
type ToolCallInput,
2728
} from './security-classifier';
2829

29-
const QUEUE = process.env.SIDEBAR_QUEUE_PATH || path.join(process.env.HOME || '/tmp', '.gstack', 'sidebar-agent-queue.jsonl');
30+
const QUEUE = process.env.SIDEBAR_QUEUE_PATH || path.join(os.homedir(), '.gstack', 'sidebar-agent-queue.jsonl');
3031
const KILL_FILE = path.join(path.dirname(QUEUE), 'sidebar-agent-kill');
3132
const SERVER_PORT = parseInt(process.env.BROWSE_SERVER_PORT || '34567', 10);
3233
const SERVER_URL = `http://127.0.0.1:${SERVER_PORT}`;
3334
const POLL_MS = 200; // 200ms poll — keeps time-to-first-token low
3435
const B = process.env.BROWSE_BIN || path.resolve(__dirname, '../../.claude/skills/gstack/browse/dist/browse');
3536

36-
const CANCEL_DIR = path.join(process.env.HOME || '/tmp', '.gstack');
37+
const CANCEL_DIR = path.join(os.homedir(), '.gstack');
3738
function cancelFileForTab(tabId: number): string {
3839
return path.join(CANCEL_DIR, `sidebar-agent-cancel-${tabId}`);
3940
}
@@ -129,7 +130,7 @@ async function refreshToken(): Promise<string | null> {
129130
// Read token from state file (same-user, mode 0o600) instead of /health
130131
try {
131132
const stateFile = process.env.BROWSE_STATE_FILE ||
132-
path.join(process.env.HOME || '/tmp', '.gstack', 'browse.json');
133+
path.join(os.homedir(), '.gstack', 'browse.json');
133134
const data = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
134135
authToken = data.token || null;
135136
return authToken;

design/prototype.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,11 @@
77
*/
88

99
import fs from "fs";
10+
import os from "os";
1011
import path from "path";
1112

1213
const API_KEY = process.env.OPENAI_API_KEY
13-
|| JSON.parse(fs.readFileSync(path.join(process.env.HOME!, ".gstack/openai.json"), "utf-8")).api_key;
14+
|| JSON.parse(fs.readFileSync(path.join(os.homedir(), ".gstack/openai.json"), "utf-8")).api_key;
1415

1516
if (!API_KEY) {
1617
console.error("No API key found. Set OPENAI_API_KEY or save to ~/.gstack/openai.json");

design/src/auth.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@
88
*/
99

1010
import fs from "fs";
11+
import os from "os";
1112
import path from "path";
1213

13-
const CONFIG_PATH = path.join(process.env.HOME || "~", ".gstack", "openai.json");
14+
const CONFIG_PATH = path.join(os.homedir(), ".gstack", "openai.json");
1415

1516
export function resolveApiKey(): string | null {
1617
// 1. Check ~/.gstack/openai.json

0 commit comments

Comments
 (0)