From f0fdd3a716c70041712928a1b9bb2a68054d5cf4 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 5 Jul 2026 21:26:30 -0700 Subject: [PATCH] Hide console windows for children of the node.exe-hosted daemon (#7486) Since #7473 the terminal daemon runs under a standalone node.exe. Electron's bundled Node defaults windowsHide to true; plain node.exe defaults it to false, so every child_process call in the daemon that does not pass the flag - the periodic PowerShell CIM process probes, node-pty's kill-path conpty_console_list_agent fork - now allocates a visible console, which opens and closes a Windows Terminal window on the user's screen every few seconds. Fix: daemon-entry installs a child_process shim (first import, before any module captures bindings like promisify(execFile)) that defaults windowsHide: true across spawn/exec/execFile/fork and their sync variants, restoring the Electron default the daemon has always relied on. Explicit windowsHide from a caller still wins. Also adds windowsHide to node-pty's console-list agent fork in the existing patch as defense in depth. Verified on Windows: reproduced the flash with the rc.5 production daemon (WindowsTerminal windows, ~3s cadence matching the CIM probe interval, conhost spawned visible-capable "0x4"); with the shim, a node.exe-hosted daemon's children (OpenConsole, powershell, node helpers) all run without a visible-capable console and session kill still works end to end. --- config/patches/node-pty@1.1.0.patch | 166 +++++++++++------- pnpm-lock.yaml | 6 +- src/main/daemon/daemon-entry.ts | 3 + .../windows-hidden-console-children.test.ts | 42 +++++ .../daemon/windows-hidden-console-children.ts | 76 ++++++++ 5 files changed, 224 insertions(+), 69 deletions(-) create mode 100644 src/main/daemon/windows-hidden-console-children.test.ts create mode 100644 src/main/daemon/windows-hidden-console-children.ts diff --git a/config/patches/node-pty@1.1.0.patch b/config/patches/node-pty@1.1.0.patch index 8d6f8b7d4..30956b536 100644 --- a/config/patches/node-pty@1.1.0.patch +++ b/config/patches/node-pty@1.1.0.patch @@ -18,7 +18,7 @@ index 1ac5758bedd8cf54f32280dea4e4aeb5afdee30d..e619813759c6f14694838bdfbd0ea5f8 +++ b/deps/winpty/src/winpty.gyp @@ -10,7 +10,7 @@ # make -j4 CXX=i686-w64-mingw32-g++ LDFLAGS="-static -static-libgcc -static-libstdc++" - + 'variables': { - 'WINPTY_COMMIT_HASH%': ' +#include #include - + #include @@ -237,13 +239,23 @@ pty_getproc(int, char *); #endif - + #if defined(__APPLE__) || defined(__OpenBSD__) +struct pty_spawn_error { + const char* step; @@ -147,12 +189,12 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056 - int* err); + pty_spawn_error* err); #endif - + struct DelBuf { @@ -367,10 +379,11 @@ Napi::Value PtyFork(const Napi::CallbackInfo& info) { argv[i + 3] = strdup(arg.c_str()); } - + - int err = -1; - pty_posix_spawn(argv, env, term, &winp, &master, &pid, &err); - if (err != 0) { @@ -167,7 +209,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056 throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking."); @@ -684,15 +697,73 @@ pty_getproc(int fd, char *tty) { #endif - + #if defined(__APPLE__) +static const char* +pty_errno_name(int errnum) { @@ -238,7 +280,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056 + bool acts_initialized = false; + posix_spawnattr_t attrs; + bool attrs_initialized = false; - + for (; count < 3; count++) { low_fds[count] = posix_openpt(O_RDWR); @@ -706,80 +777,118 @@ pty_posix_spawn(char** argv, char** env, @@ -255,7 +297,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056 + pty_set_spawn_error(err, "grantpt", errno); + goto done; } - + - int res = grantpt(*master) || unlockpt(*master); + res = unlockpt(*master); if (res == -1) { @@ -263,7 +305,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056 + pty_set_spawn_error(err, "unlockpt", errno); + goto done; } - + // Use TIOCPTYGNAME instead of ptsname() to avoid threading problems. - int slave; char slave_pty_name[128]; @@ -273,14 +315,14 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056 + pty_set_spawn_error(err, "ioctl_TIOCPTYGNAME", errno); + goto done; } - + slave = open(slave_pty_name, O_RDWR | O_NOCTTY); if (slave == -1) { - return; + pty_set_spawn_error(err, "open_slave", errno, "slave", slave_pty_name); + goto done; } - + if (termp) { res = tcsetattr(slave, TCSANOW, termp); if (res == -1) { @@ -289,7 +331,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056 + goto done; }; } - + if (winp) { res = ioctl(slave, TIOCSWINSZ, winp); if (res == -1) { @@ -298,7 +340,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056 + goto done; } } - + - posix_spawn_file_actions_t acts; - posix_spawn_file_actions_init(&acts); + res = posix_spawn_file_actions_init(&acts); @@ -312,7 +354,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056 posix_spawn_file_actions_adddup2(&acts, slave, STDERR_FILENO); posix_spawn_file_actions_addclose(&acts, slave); posix_spawn_file_actions_addclose(&acts, *master); - + - posix_spawnattr_t attrs; - posix_spawnattr_init(&attrs); - *err = posix_spawnattr_setflags(&attrs, flags); @@ -328,7 +370,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056 + pty_set_spawn_error(err, "posix_spawnattr_setflags", res); goto done; } - + sigset_t signal_set; /* Reset all signal the child to their default behavior */ sigfillset(&signal_set); @@ -339,7 +381,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056 + pty_set_spawn_error(err, "posix_spawnattr_setsigdefault", res); goto done; } - + /* Reset the signal mask for all signals */ sigemptyset(&signal_set); - *err = posix_spawnattr_setsigmask(&attrs, &signal_set); @@ -349,7 +391,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056 + pty_set_spawn_error(err, "posix_spawnattr_setsigmask", res); goto done; } - + do - *err = posix_spawn(pid, argv[0], &acts, &attrs, argv, env); - while (*err == EINTR); @@ -374,7 +416,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056 + close(*master); + *master = -1; + } - + - for (; count > 0; count--) { - close(low_fds[count]); + for (size_t i = 0; i <= count && i < 3; i++) { @@ -384,27 +426,19 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056 } } #endif -diff --git a/lib/utils.js b/lib/utils.js ---- a/lib/utils.js -+++ b/lib/utils.js -@@ -19,7 +19,20 @@ function loadNativeModule(name) { - var dirs = ['build/Release', 'build/Debug', "prebuilds/" + process.platform + "-" + process.arch]; - // Check relative to the parent dir for unbundled and then the current dir for bundled - var relative = ['..', '.']; - var lastError; -+ // Why (Orca): the Windows updater force-closes every process whose image -+ // lives under the app install dir; this env var relocates the native -+ // binaries (conpty.dll spawns OpenConsole.exe from beside itself) so PTY -+ // console hosts run outside it and survive updates. -+ var overrideDir = process.env.ORCA_NODE_PTY_NATIVE_DIR; -+ if (overrideDir) { -+ try { -+ return { dir: overrideDir, module: require(overrideDir + "/" + name + ".node") }; -+ } -+ catch (e) { -+ lastError = e; -+ } -+ } - for (var _i = 0, dirs_1 = dirs; _i < dirs_1.length; _i++) { - var d = dirs_1[_i]; - for (var _a = 0, relative_1 = relative; _a < relative_1.length; _a++) { +diff --git a/src/windowsPtyAgent.ts b/src/windowsPtyAgent.ts +index d7054449516f0c9a62af351c2caa17331206d530..d6f0892f614d36b42f32547e81d93cabb268c601 100644 +--- a/src/windowsPtyAgent.ts ++++ b/src/windowsPtyAgent.ts +@@ -184,7 +184,10 @@ export class WindowsPtyAgent { + + private _getConsoleProcessList(): Promise { + return new Promise(resolve => { +- const agent = fork(path.join(__dirname, 'conpty_console_list_agent'), [ this._innerPid.toString() ]); ++ // Why (Orca): the terminal daemon can run under a console-subsystem ++ // node.exe host; without windowsHide this fork flashes a visible ++ // console window on every session kill. ++ const agent = fork(path.join(__dirname, 'conpty_console_list_agent'), [ this._innerPid.toString() ], { windowsHide: true }); + agent.on('message', message => { + clearTimeout(timeout); + resolve(message.consoleProcessList); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d8aff1b4b..f3a1a3369 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,7 +12,7 @@ patchedDependencies: hash: 296e716bb67c0aa3d4ff47d493b9fd9ba9518b7e9f6597005fd680ac04274145 path: config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch node-pty@1.1.0: - hash: b354dc2fd021578ac4829e77a2666ff192a517ba3a8428337a70ecea930aea88 + hash: 1b6a77f2c7772dffee8c92116eeb470ae52cd58fe7706755027e78792d684b8a path: config/patches/node-pty@1.1.0.patch importers: @@ -54,7 +54,7 @@ importers: version: 3.3.1 node-pty: specifier: ^1.1.0 - version: 1.1.0(patch_hash=b354dc2fd021578ac4829e77a2666ff192a517ba3a8428337a70ecea930aea88) + version: 1.1.0(patch_hash=1b6a77f2c7772dffee8c92116eeb470ae52cd58fe7706755027e78792d684b8a) posthog-node: specifier: ^5.33.3 version: 5.33.3 @@ -11747,7 +11747,7 @@ snapshots: undici: 6.27.0 which: 6.0.1 - node-pty@1.1.0(patch_hash=b354dc2fd021578ac4829e77a2666ff192a517ba3a8428337a70ecea930aea88): + node-pty@1.1.0(patch_hash=1b6a77f2c7772dffee8c92116eeb470ae52cd58fe7706755027e78792d684b8a): dependencies: node-addon-api: 7.1.1 diff --git a/src/main/daemon/daemon-entry.ts b/src/main/daemon/daemon-entry.ts index aeffb7aa2..2e64c3d0a 100644 --- a/src/main/daemon/daemon-entry.ts +++ b/src/main/daemon/daemon-entry.ts @@ -6,6 +6,9 @@ * Signals readiness to parent via IPC: { type: 'ready' } * Shuts down cleanly on SIGTERM. */ +// Why first: patches child_process defaults (windowsHide) before any other +// module captures bindings like promisify(execFile) at its own import time. +import './windows-hidden-console-children' import { startDaemon, type DaemonHandle } from './daemon-main' import { createPtySubprocess } from './pty-subprocess' import { warmWindowsConptyOnce } from './windows-conpty-warmup' diff --git a/src/main/daemon/windows-hidden-console-children.test.ts b/src/main/daemon/windows-hidden-console-children.test.ts new file mode 100644 index 000000000..da5c1d013 --- /dev/null +++ b/src/main/daemon/windows-hidden-console-children.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { withHiddenConsoleDefault } from './windows-hidden-console-children' + +describe('withHiddenConsoleDefault', () => { + it('injects windowsHide into an existing options object', () => { + const args = withHiddenConsoleDefault(['powershell.exe', ['-NoProfile'], { cwd: 'C:\\x' }]) + expect(args[2]).toEqual({ windowsHide: true, cwd: 'C:\\x' }) + }) + + it('never overrides an explicit windowsHide: false', () => { + const args = withHiddenConsoleDefault(['cmd.exe', { windowsHide: false }]) + expect(args[1]).toEqual({ windowsHide: false }) + }) + + it('appends options when the call has none (spawn style)', () => { + const args = withHiddenConsoleDefault(['cmd.exe', ['/c', 'echo hi']]) + expect(args).toEqual(['cmd.exe', ['/c', 'echo hi'], { windowsHide: true }]) + }) + + it('appends options for a bare command', () => { + expect(withHiddenConsoleDefault(['cmd.exe'])).toEqual(['cmd.exe', { windowsHide: true }]) + }) + + it('inserts options before a trailing callback (exec style)', () => { + const cb = (): void => {} + const args = withHiddenConsoleDefault(['echo hi', cb]) + expect(args).toEqual(['echo hi', { windowsHide: true }, cb]) + }) + + it('merges into options that sit between args array and callback (execFile style)', () => { + const cb = (): void => {} + const args = withHiddenConsoleDefault(['node', ['-v'], { timeout: 5 }, cb]) + expect(args).toEqual(['node', ['-v'], { windowsHide: true, timeout: 5 }, cb]) + }) + + it('does not mistake the command or args array for options', () => { + const args = withHiddenConsoleDefault(['node', ['-v']]) + expect(args[0]).toBe('node') + expect(args[1]).toEqual(['-v']) + expect(args[2]).toEqual({ windowsHide: true }) + }) +}) diff --git a/src/main/daemon/windows-hidden-console-children.ts b/src/main/daemon/windows-hidden-console-children.ts new file mode 100644 index 000000000..39581f666 --- /dev/null +++ b/src/main/daemon/windows-hidden-console-children.ts @@ -0,0 +1,76 @@ +import childProcess from 'node:child_process' + +/** + * Defaults `windowsHide: true` for every child_process API in this process. + * + * Why: Electron's bundled Node patches the `windowsHide` default to true, and + * the daemon historically ran under Electron-as-Node, so none of its spawn + * call sites (PowerShell CIM probes, node-pty's console-list helper, agent + * detection) pass the flag. Hosted by a standalone node.exe the default is + * false — with a console-subsystem host every such child allocates a console, + * which flashes a visible window (Windows Terminal when it is the default + * host) on each periodic probe. Restore the Electron default process-wide. + */ + +const PATCHED_APIS = [ + 'spawn', + 'spawnSync', + 'exec', + 'execSync', + 'execFile', + 'execFileSync', + 'fork' +] as const + +let installed = false + +function isPlainOptionsObject(value: unknown): value is Record { + return ( + typeof value === 'object' && value !== null && !Array.isArray(value) && !Buffer.isBuffer(value) + ) +} + +/** + * Returns the argument list with `windowsHide: true` merged into its options + * object. Handles every child_process signature: options may sit at any + * position after the command, may be followed by a callback (exec/execFile), + * or may be absent entirely. An explicit caller-provided windowsHide wins. + */ +export function withHiddenConsoleDefault(args: unknown[]): unknown[] { + for (let i = 1; i < args.length; i++) { + if (isPlainOptionsObject(args[i])) { + const next = [...args] + next[i] = { windowsHide: true, ...(args[i] as Record) } + return next + } + } + // No options object: insert one before a trailing callback, else append. + const next = [...args] + const insertAt = typeof next.at(-1) === 'function' ? next.length - 1 : next.length + next.splice(insertAt, 0, { windowsHide: true }) + return next +} + +export function installHiddenConsoleChildDefaults(): void { + if (installed || process.platform !== 'win32') { + return + } + installed = true + for (const name of PATCHED_APIS) { + const original = childProcess[name] as (...args: unknown[]) => unknown + const wrapped = (...args: unknown[]): unknown => original(...withHiddenConsoleDefault(args)) + // Why: exec/execFile carry promisify custom symbols; copy them so + // util.promisify keeps returning {stdout, stderr} promises. + Object.setPrototypeOf(wrapped, original) + for (const sym of Object.getOwnPropertySymbols(original)) { + Object.defineProperty(wrapped, sym, Object.getOwnPropertyDescriptor(original, sym)!) + } + ;(childProcess as Record)[name] = wrapped + } +} + +// Why install on import (not via an exported call): modules capture bindings +// like `promisify(execFile)` at their own import time, so the patch must land +// before ANY other daemon module evaluates. daemon-entry imports this module +// first for that reason. +installHiddenConsoleChildDefaults()