From 5d4338bdac3021017236ac7f1a7361ce36ade72c Mon Sep 17 00:00:00 2001 From: yaojin Date: Tue, 18 Aug 2026 16:34:55 +0800 Subject: [PATCH] fix: hide PowerShell and other child process windows on Windows The harness Node.js process was already started with windowsHide: true, but DSH spawns its own child processes (pwsh, etc.) without that flag, causing visible console windows that steal focus on Windows. Add a monkey-patch in harness-node-entry.mjs that defaults windowsHide to true for all child_process functions (spawn, spawnSync, exec, execSync, execFile, execFileSync, fork) when running on win32. Callers can still explicitly set windowsHide: false to override. Closes #35 --- build/harness-node-entry.mjs | 72 +++++++++ test/harness-node-entry.test.ts | 266 ++++++++++++++++++++++++++++++++ 2 files changed, 338 insertions(+) create mode 100644 test/harness-node-entry.test.ts diff --git a/build/harness-node-entry.mjs b/build/harness-node-entry.mjs index c86fbd90..b7118f75 100644 --- a/build/harness-node-entry.mjs +++ b/build/harness-node-entry.mjs @@ -1,3 +1,4 @@ +import childProcess from 'node:child_process' import { pathToFileURL } from 'node:url' const [dshEntryPath, ...dshArguments] = process.argv.slice(2) @@ -16,6 +17,77 @@ process.stdout.write(`[harness-node] execPath=${process.execPath}\n`) process.stdout.write(`[harness-node] cwd=${process.cwd()}\n`) process.stdout.write(`[harness-node] DSH_HOME=${process.env.DSH_HOME ?? ''}\n`) +if (process.platform === 'win32') { + const originalSpawn = childProcess.spawn + const originalSpawnSync = childProcess.spawnSync + const originalExec = childProcess.exec + const originalExecSync = childProcess.execSync + const originalExecFile = childProcess.execFile + const originalExecFileSync = childProcess.execFileSync + const originalFork = childProcess.fork + + function applyWindowsHide(options) { + if (options && typeof options === 'object' && options.windowsHide !== undefined) { + return options + } + if (options && typeof options === 'object') { + return { ...options, windowsHide: true } + } + return { windowsHide: true } + } + + childProcess.spawn = function patchedSpawn(command, args, options) { + if (Array.isArray(args)) { + return originalSpawn.call(this, command, args, applyWindowsHide(options)) + } + return originalSpawn.call(this, command, applyWindowsHide(args)) + } + + childProcess.spawnSync = function patchedSpawnSync(command, args, options) { + if (Array.isArray(args)) { + return originalSpawnSync.call(this, command, args, applyWindowsHide(options)) + } + return originalSpawnSync.call(this, command, applyWindowsHide(args)) + } + + childProcess.exec = function patchedExec(command, options, callback) { + if (typeof options === 'function') { + return originalExec.call(this, command, applyWindowsHide(undefined), options) + } + return originalExec.call(this, command, applyWindowsHide(options), callback) + } + + childProcess.execSync = function patchedExecSync(command, options) { + return originalExecSync.call(this, command, applyWindowsHide(options)) + } + + childProcess.execFile = function patchedExecFile(file, args, options, callback) { + if (typeof args === 'function') { + return originalExecFile.call(this, file, applyWindowsHide(undefined), undefined, args) + } + if (typeof options === 'function') { + return originalExecFile.call(this, file, args, applyWindowsHide(undefined), options) + } + return originalExecFile.call(this, file, args, applyWindowsHide(options), callback) + } + + childProcess.execFileSync = function patchedExecFileSync(file, args, options) { + if (args && !Array.isArray(args)) { + return originalExecFileSync.call(this, file, applyWindowsHide(args)) + } + return originalExecFileSync.call(this, file, args, applyWindowsHide(options)) + } + + childProcess.fork = function patchedFork(modulePath, args, options) { + if (Array.isArray(args)) { + return originalFork.call(this, modulePath, args, applyWindowsHide(options)) + } + return originalFork.call(this, modulePath, applyWindowsHide(args)) + } + + process.stdout.write('[harness-node] windowsHide enforcement enabled for child processes\n') +} + if (!dshEntryPath) { report('startup error', 'missing DSH entry path') process.exitCode = 1 diff --git a/test/harness-node-entry.test.ts b/test/harness-node-entry.test.ts new file mode 100644 index 00000000..f12c64ca --- /dev/null +++ b/test/harness-node-entry.test.ts @@ -0,0 +1,266 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import childProcess from 'node:child_process' + +function applyWindowsHide(options: any): any { + if (options && typeof options === 'object' && options.windowsHide !== undefined) { + return options + } + if (options && typeof options === 'object') { + return { ...options, windowsHide: true } + } + return { windowsHide: true } +} + +describe('applyWindowsHide helper', () => { + it('adds windowsHide: true when options is undefined', () => { + expect(applyWindowsHide(undefined)).toEqual({ windowsHide: true }) + }) + + it('adds windowsHide: true to existing options', () => { + expect(applyWindowsHide({ cwd: '/tmp' })).toEqual({ cwd: '/tmp', windowsHide: true }) + }) + + it('preserves explicit windowsHide: false', () => { + expect(applyWindowsHide({ windowsHide: false })).toEqual({ windowsHide: false }) + }) + + it('preserves explicit windowsHide: true', () => { + expect(applyWindowsHide({ windowsHide: true })).toEqual({ windowsHide: true }) + }) + + it('adds windowsHide: true to empty options object', () => { + expect(applyWindowsHide({})).toEqual({ windowsHide: true }) + }) + + it('preserves all other options alongside windowsHide', () => { + expect(applyWindowsHide({ cwd: 'C:\\test', env: { FOO: 'bar' }, stdio: 'pipe' })).toEqual({ + cwd: 'C:\\test', + env: { FOO: 'bar' }, + stdio: 'pipe', + windowsHide: true + }) + }) +}) + +describe('windowsHide patching for spawn', () => { + let spawnSpy: ReturnType + + beforeEach(() => { + spawnSpy = vi.fn(() => ({ on: vi.fn(), stdout: { on: vi.fn() }, stderr: { on: vi.fn() } })) + }) + + function makePatchedSpawn(original: any) { + return function patchedSpawn(command: string, args?: string[] | any, options?: any) { + if (Array.isArray(args)) { + return original(command, args, applyWindowsHide(options)) + } + return original(command, applyWindowsHide(args)) + } + } + + it('injects windowsHide: true for spawn(command, args, options)', () => { + const patched = makePatchedSpawn(spawnSpy) + patched('pwsh', ['-Command', 'echo hi'], { cwd: 'C:\\test' }) + expect(spawnSpy).toHaveBeenCalledWith( + 'pwsh', + ['-Command', 'echo hi'], + { cwd: 'C:\\test', windowsHide: true } + ) + }) + + it('injects windowsHide: true for spawn(command, options) (no args array)', () => { + const patched = makePatchedSpawn(spawnSpy) + patched('pwsh', { cwd: 'C:\\test' }) + expect(spawnSpy).toHaveBeenCalledWith( + 'pwsh', + { cwd: 'C:\\test', windowsHide: true } + ) + }) + + it('respects explicit windowsHide: false override', () => { + const patched = makePatchedSpawn(spawnSpy) + patched('pwsh', ['-Command', 'echo hi'], { windowsHide: false }) + expect(spawnSpy).toHaveBeenCalledWith( + 'pwsh', + ['-Command', 'echo hi'], + { windowsHide: false } + ) + }) + + it('works with no args and no options', () => { + const patched = makePatchedSpawn(spawnSpy) + patched('pwsh') + expect(spawnSpy).toHaveBeenCalledWith( + 'pwsh', + { windowsHide: true } + ) + }) +}) + +describe('windowsHide patching for spawnSync', () => { + let spawnSyncSpy: ReturnType + + beforeEach(() => { + spawnSyncSpy = vi.fn(() => ({ status: 0, stdout: '', stderr: '' })) + }) + + function makePatchedSpawnSync(original: any) { + return function patchedSpawnSync(command: string, args?: string[] | any, options?: any) { + if (Array.isArray(args)) { + return original(command, args, applyWindowsHide(options)) + } + return original(command, applyWindowsHide(args)) + } + } + + it('injects windowsHide: true with args and options', () => { + const patched = makePatchedSpawnSync(spawnSyncSpy) + patched('pwsh', ['-Command', 'echo hi'], { encoding: 'utf8' }) + expect(spawnSyncSpy).toHaveBeenCalledWith( + 'pwsh', + ['-Command', 'echo hi'], + { encoding: 'utf8', windowsHide: true } + ) + }) +}) + +describe('windowsHide patching for exec', () => { + let execSpy: ReturnType + + beforeEach(() => { + execSpy = vi.fn(() => ({ on: vi.fn() })) + }) + + function makePatchedExec(original: any) { + return function patchedExec(command: string, options?: any, callback?: any) { + if (typeof options === 'function') { + return original(command, applyWindowsHide(undefined), options) + } + return original(command, applyWindowsHide(options), callback) + } + } + + it('injects windowsHide: true with options and callback', () => { + const patched = makePatchedExec(execSpy) + const cb = () => {} + patched('pwsh -Command echo hi', { cwd: 'C:\\test' }, cb) + expect(execSpy).toHaveBeenCalledWith( + 'pwsh -Command echo hi', + { cwd: 'C:\\test', windowsHide: true }, + cb + ) + }) + + it('injects windowsHide: true with callback only (no options)', () => { + const patched = makePatchedExec(execSpy) + const cb = () => {} + patched('pwsh -Command echo hi', cb) + expect(execSpy).toHaveBeenCalledWith( + 'pwsh -Command echo hi', + { windowsHide: true }, + cb + ) + }) + + it('injects windowsHide: true with options only (no callback)', () => { + const patched = makePatchedExec(execSpy) + patched('pwsh -Command echo hi', { cwd: 'C:\\test' }) + expect(execSpy).toHaveBeenCalledWith( + 'pwsh -Command echo hi', + { cwd: 'C:\\test', windowsHide: true }, + undefined + ) + }) +}) + +describe('windowsHide patching for execFile', () => { + let execFileSpy: ReturnType + + beforeEach(() => { + execFileSpy = vi.fn(() => ({ on: vi.fn() })) + }) + + function makePatchedExecFile(original: any) { + return function patchedExecFile(file: string, args?: any, options?: any, callback?: any) { + if (typeof args === 'function') { + return original(file, applyWindowsHide(undefined), undefined, args) + } + if (typeof options === 'function') { + return original(file, args, applyWindowsHide(undefined), options) + } + return original(file, args, applyWindowsHide(options), callback) + } + } + + it('injects windowsHide: true with args, options, and callback', () => { + const patched = makePatchedExecFile(execFileSpy) + const cb = () => {} + patched('pwsh', ['-Command', 'echo hi'], { cwd: 'C:\\test' }, cb) + expect(execFileSpy).toHaveBeenCalledWith( + 'pwsh', + ['-Command', 'echo hi'], + { cwd: 'C:\\test', windowsHide: true }, + cb + ) + }) + + it('injects windowsHide: true with args and callback (no options)', () => { + const patched = makePatchedExecFile(execFileSpy) + const cb = () => {} + patched('pwsh', ['-Command', 'echo hi'], cb) + expect(execFileSpy).toHaveBeenCalledWith( + 'pwsh', + ['-Command', 'echo hi'], + { windowsHide: true }, + cb + ) + }) + + it('injects windowsHide: true with callback only (no args, no options)', () => { + const patched = makePatchedExecFile(execFileSpy) + const cb = () => {} + patched('pwsh', cb) + expect(execFileSpy).toHaveBeenCalledWith( + 'pwsh', + { windowsHide: true }, + undefined, + cb + ) + }) +}) + +describe('windowsHide patching for fork', () => { + let forkSpy: ReturnType + + beforeEach(() => { + forkSpy = vi.fn(() => ({ on: vi.fn(), send: vi.fn() })) + }) + + function makePatchedFork(original: any) { + return function patchedFork(modulePath: string, args?: string[] | any, options?: any) { + if (Array.isArray(args)) { + return original(modulePath, args, applyWindowsHide(options)) + } + return original(modulePath, applyWindowsHide(args)) + } + } + + it('injects windowsHide: true with args and options', () => { + const patched = makePatchedFork(forkSpy) + patched('/path/to/worker.js', ['--foo'], { cwd: 'C:\\test' }) + expect(forkSpy).toHaveBeenCalledWith( + '/path/to/worker.js', + ['--foo'], + { cwd: 'C:\\test', windowsHide: true } + ) + }) + + it('injects windowsHide: true with options only (no args)', () => { + const patched = makePatchedFork(forkSpy) + patched('/path/to/worker.js', { cwd: 'C:\\test' }) + expect(forkSpy).toHaveBeenCalledWith( + '/path/to/worker.js', + { cwd: 'C:\\test', windowsHide: true } + ) + }) +})