diff --git a/browse/src/browse-client.ts b/browse/src/browse-client.ts index 435f57546..499b366f7 100644 --- a/browse/src/browse-client.ts +++ b/browse/src/browse-client.ts @@ -103,7 +103,7 @@ export function resolveBrowseAuth(opts: BrowseClientOptions = {}): ResolvedAuth function defaultStateFile(): string | null { try { - const proc = cp.spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf-8', timeout: 2000 }); + const proc = cp.spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf-8', timeout: 2000, windowsHide: true }); const root = proc.status === 0 ? proc.stdout.trim() : null; const base = root || process.cwd(); return path.join(base, '.gstack', 'browse.json'); diff --git a/browse/src/browser-skills.ts b/browse/src/browser-skills.ts index 5bf7241be..277f38096 100644 --- a/browse/src/browser-skills.ts +++ b/browse/src/browser-skills.ts @@ -98,7 +98,7 @@ export function defaultTierPaths(opts: { projectRoot?: string; home?: string; bu function detectProjectRoot(): string | null { try { - const proc = cp.spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf-8', timeout: 2000 }); + const proc = cp.spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf-8', timeout: 2000, windowsHide: true }); if (proc.status === 0) { const out = proc.stdout.trim(); return out || null; diff --git a/browse/src/cli.ts b/browse/src/cli.ts index 9732053c7..ab90dd918 100644 --- a/browse/src/cli.ts +++ b/browse/src/cli.ts @@ -310,6 +310,7 @@ function raiseHeadedWindowMacOS(): void { nodeSpawn('osascript', ['-e', 'tell application "Google Chrome for Testing" to activate'], { stdio: 'ignore', detached: true, + windowsHide: true, }).unref(); } catch { // osascript missing or app not present — non-fatal diff --git a/browse/src/file-permissions.ts b/browse/src/file-permissions.ts index 5a5c5462e..078fcac89 100644 --- a/browse/src/file-permissions.ts +++ b/browse/src/file-permissions.ts @@ -60,6 +60,7 @@ function currentUserSid(): string | null { const systemRoot = process.env.SystemRoot || process.env.windir || 'C:\\Windows'; const out = execFileSync(`${systemRoot}\\System32\\whoami.exe`, ['/user', '/fo', 'csv', '/nh'], { encoding: 'utf8', + windowsHide: true, }); const match = out.match(/S-1-[\d-]+/); cachedSid = match ? match[0] : null; diff --git a/browse/src/find-security-sidecar.ts b/browse/src/find-security-sidecar.ts index 0ba242523..aeee96685 100644 --- a/browse/src/find-security-sidecar.ts +++ b/browse/src/find-security-sidecar.ts @@ -30,7 +30,7 @@ export interface SidecarLocation { function nodeOnPath(): string | null { try { - execFileSync("node", ["--version"], { stdio: "ignore", timeout: 2000 }); + execFileSync("node", ["--version"], { stdio: "ignore", timeout: 2000, windowsHide: true }); return "node"; } catch { return null; diff --git a/browse/src/meta-commands.ts b/browse/src/meta-commands.ts index 521c65a20..a2bc1f4d2 100644 --- a/browse/src/meta-commands.ts +++ b/browse/src/meta-commands.ts @@ -777,7 +777,7 @@ export async function handleMetaCommand( let activated = false; for (const appName of appNames) { try { - execSync(`osascript -e 'tell application "${appName}" to activate'`, { stdio: 'pipe', timeout: 3000 }); + execSync(`osascript -e 'tell application "${appName}" to activate'`, { stdio: 'pipe', timeout: 3000, windowsHide: true }); activated = true; break; } catch (err: any) { @@ -841,7 +841,7 @@ export async function handleMetaCommand( const { execSync } = await import('child_process'); let gitRoot: string; try { - gitRoot = execSync('git rev-parse --show-toplevel', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); + gitRoot = execSync('git rev-parse --show-toplevel', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }).trim(); } catch (err: any) { // execSync throws with exit status on non-git directories if (err?.status === undefined && !err?.message?.includes('Command failed')) throw err; diff --git a/browse/src/project-slug.ts b/browse/src/project-slug.ts index 0a840ebea..2ad34e754 100644 --- a/browse/src/project-slug.ts +++ b/browse/src/project-slug.ts @@ -21,7 +21,7 @@ export function getCurrentProjectSlug(): string { } try { const slugBin = path.join(os.homedir(), '.claude/skills/gstack/bin/gstack-slug'); - const out = execSync(slugBin, { encoding: 'utf8', timeout: 2000 }).trim(); + const out = execSync(slugBin, { encoding: 'utf8', timeout: 2000, windowsHide: true }).trim(); const m = out.match(/SLUG="?([^"\n]+)"?/); cachedSlug = m ? m[1]! : (out || 'unknown'); } catch { diff --git a/browse/src/security-sidecar-client.ts b/browse/src/security-sidecar-client.ts index da481671a..95e226070 100644 --- a/browse/src/security-sidecar-client.ts +++ b/browse/src/security-sidecar-client.ts @@ -138,6 +138,9 @@ function spawnSidecar(): boolean { const child = spawn(location.node, [location.entry], { stdio: ["pipe", "pipe", "pipe"], detached: false, + // Long-lived Node sidecar — without this, Windows gives it a console + // window that sits on the taskbar for the daemon's whole lifetime. + windowsHide: true, }); child.stdout.on("data", (chunk: Buffer) => { s.buffer += chunk.toString("utf-8"); diff --git a/browse/test/windows-spawn-hide.test.ts b/browse/test/windows-spawn-hide.test.ts index 58eb59640..a9949a566 100644 --- a/browse/test/windows-spawn-hide.test.ts +++ b/browse/test/windows-spawn-hide.test.ts @@ -62,4 +62,69 @@ describe('windowsHide on Windows-reachable spawns (#1835)', () => { // spawn's options object carries the full env wiring before the flag. expectHideNearEvery(SRC('terminal-agent-control.ts'), '(Bun as any).spawn(', 700); }); + + test('SWEEP: every direct child_process call in src/ passes windowsHide (#2160, #2415)', () => { + // Full-census tripwire: a NEW child_process call site without windowsHide + // fails CI. Each exemption carries a reason — an interactive console + // child must NOT get CREATE_NO_WINDOW. + const EXEMPT: Array<{ file: string; needle: string; reason: string }> = [ + { + file: 'domain-skill-commands.ts', + needle: 'spawnSync(editor', + reason: "interactive $EDITOR with stdio:'inherit' — windowsHide would detach a console editor into an invisible console", + }, + ]; + + const srcDir = path.join(import.meta.dir, '../src'); + const offenders: string[] = []; + for (const file of fs.readdirSync(srcDir).filter((f) => f.endsWith('.ts'))) { + const raw = fs.readFileSync(path.join(srcDir, file), 'utf-8'); + if (!raw.includes('child_process')) continue; + // Strip comments so documented history doesn't trip the census. + const code = raw.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); + + // Collect the callable names this file binds to child_process: + // import { spawn as nodeSpawn } from 'child_process' + // const { execSync } = await import('child_process') / require(...) + // import * as cp from 'child_process' → cp.( pattern + const names = new Set(); + const namespaces = new Set(); + const importRe = /import\s*\{([^}]*)\}\s*from\s*['"](?:node:)?child_process['"]/g; + const dynRe = /(?:const|let|var)\s*\{([^}]*)\}\s*=\s*(?:await\s+import\(|require\()['"](?:node:)?child_process['"]\)/g; + const nsRe = /import\s*\*\s*as\s*(\w+)\s*from\s*['"](?:node:)?child_process['"]/g; + for (const m of code.matchAll(importRe)) { + for (const part of m[1].split(',')) { + const alias = part.split(/\s+as\s+/).map((s) => s.trim()).filter(Boolean); + const name = alias[alias.length - 1]; + if (name && /^(spawn|spawnSync|exec|execSync|execFile|execFileSync|nodeSpawn|cpSpawn)/.test(alias[0].trim())) names.add(name); + } + } + for (const m of code.matchAll(dynRe)) { + for (const part of m[1].split(',')) { + const alias = part.split(':').map((s) => s.trim()).filter(Boolean); + const name = alias[alias.length - 1]; + if (name && /^(spawn|spawnSync|exec|execSync|execFile|execFileSync)/.test(alias[0].trim())) names.add(name); + } + } + for (const m of code.matchAll(nsRe)) namespaces.add(m[1]); + + const patterns: RegExp[] = []; + for (const n of names) patterns.push(new RegExp(`(? e.file === file && slice.startsWith(e.needle)); + if (exempt) continue; + if (!/windowsHide:\s*true/.test(slice)) { + offenders.push(`${file}: ${slice.split('\n')[0].slice(0, 100)}`); + } + } + } + } + expect(offenders).toEqual([]); + }); }); diff --git a/browser-skills/hackernews-frontpage/_lib/browse-client.ts b/browser-skills/hackernews-frontpage/_lib/browse-client.ts index 435f57546..499b366f7 100644 --- a/browser-skills/hackernews-frontpage/_lib/browse-client.ts +++ b/browser-skills/hackernews-frontpage/_lib/browse-client.ts @@ -103,7 +103,7 @@ export function resolveBrowseAuth(opts: BrowseClientOptions = {}): ResolvedAuth function defaultStateFile(): string | null { try { - const proc = cp.spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf-8', timeout: 2000 }); + const proc = cp.spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf-8', timeout: 2000, windowsHide: true }); const root = proc.status === 0 ? proc.stdout.trim() : null; const base = root || process.cwd(); return path.join(base, '.gstack', 'browse.json');