Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions apps/desktop/electron/windows-child-process.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,19 @@ function requireHiddenChildOptions(source, needle) {
)
}

function requireAllHiddenChildOptions(source, needle) {
const matches = [...source.matchAll(needle)]
assert.ok(matches.length > 0, `missing call site: ${needle}`)
for (const match of matches) {
const snippet = source.slice(match.index, match.index + 700)
assert.match(
snippet,
/hiddenWindowsChildOptions\(/,
`expected ${needle} call site to wrap child-process options with hiddenWindowsChildOptions`
)
}
}

test('desktop background child processes opt into hidden Windows consoles', () => {
const source = readElectronFile('main.cjs')

Expand All @@ -37,6 +50,7 @@ test('desktop background child processes opt into hidden Windows consoles', () =
requireHiddenChildOptions(source, /spawn\(\s*backend\.command,\s*backend\.args/)
requireHiddenChildOptions(source, /hermesProcess = spawn\(\s*backend\.command,\s*backend\.args/)
requireHiddenChildOptions(source, /spawn\(\s*py,\s*\['-m', 'hermes_cli\.main', 'uninstall', '--gui-summary'\]/)
requireAllHiddenChildOptions(source, /spawn\(\s*updater,\s*updaterArgs,/g)

assert.match(source, /function unwrapWindowsVenvHermesCommand\(command, dashboardArgs\)/)
assert.match(source, /existing Hermes no-console Python at/)
Expand All @@ -56,7 +70,6 @@ test('desktop background child processes opt into hidden Windows consoles', () =
test('intentional or interactive desktop child processes stay documented', () => {
const source = readElectronFile('main.cjs')

assert.match(source, /windowsHide: false/)
assert.match(source, /handOffWindowsBootstrapRecovery/)
assert.match(source, /'--repair', '--branch'/)
assert.match(source, /'--update', '--branch'/)
Expand All @@ -68,5 +81,5 @@ test('bootstrap PowerShell runner hides Windows console children', () => {
const source = readElectronFile('bootstrap-runner.cjs')

assert.match(source, /function hiddenWindowsChildOptions\(options = \{\}\)/)
requireHiddenChildOptions(source, 'spawn(ps, fullArgs')
requireHiddenChildOptions(source, /spawn\(\s*ps,\s*fullArgs/)
})
60 changes: 60 additions & 0 deletions hermes_bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,69 @@
from __future__ import annotations

import os
import subprocess
import sys

_IS_WINDOWS = sys.platform == "win32"
_bootstrap_applied = False
_subprocess_defaults_applied = False
_original_popen = subprocess.Popen


def _hidden_windows_startupinfo():
if not _IS_WINDOWS:
return None
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
startupinfo.wShowWindow = subprocess.SW_HIDE
return startupinfo


def _apply_windows_subprocess_defaults(kwargs: dict) -> dict:
"""Default Windows child processes to hidden unless caller is explicit."""
if not _IS_WINDOWS:
return kwargs
if os.environ.get("HERMES_ALLOW_VISIBLE_SUBPROCESSES") == "1":
return kwargs
creationflags = kwargs.get("creationflags", 0) or 0
create_new_console = getattr(subprocess, "CREATE_NEW_CONSOLE", 0x00000010)
if not creationflags & create_new_console:
kwargs["creationflags"] = creationflags | getattr(
subprocess, "CREATE_NO_WINDOW", 0
)
if kwargs.get("startupinfo") is None:
startupinfo = _hidden_windows_startupinfo()
if startupinfo is not None:
kwargs["startupinfo"] = startupinfo
return kwargs


def apply_windows_subprocess_defaults() -> bool:
"""Hide Python-spawned child process windows by default on Windows.

Hermes' desktop/gateway backends launch many short-lived helpers while the
UI is connecting, loading sessions, resolving providers, or discovering
tools. Any raw ``subprocess.run`` / ``Popen`` call that forgets
``CREATE_NO_WINDOW`` can briefly flash a Windows Terminal/cmd window.

This process-wide wrapper makes hidden launches the default for Hermes
Python entry points. Callers that need special process behavior remain in
control by passing explicit ``creationflags`` and/or ``startupinfo``.
"""
global _subprocess_defaults_applied

if not _IS_WINDOWS:
return False
if _subprocess_defaults_applied:
return False

class HermesHiddenPopen(_original_popen):
def __init__(self, *args, **kwargs):
super().__init__(*args, **_apply_windows_subprocess_defaults(kwargs))

subprocess.Popen = HermesHiddenPopen
_subprocess_defaults_applied = True
return True


def apply_windows_utf8_bootstrap() -> bool:
Expand All @@ -79,6 +138,7 @@ def apply_windows_utf8_bootstrap() -> bool:
# (or PYTHONIOENCODING=something-else) if they really want to.
os.environ.setdefault("PYTHONUTF8", "1")
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
apply_windows_subprocess_defaults()

# 2. Reconfigure the current process's stdio to UTF-8. Needed
# because os.environ changes don't retroactively rebind sys.stdout
Expand Down
40 changes: 39 additions & 1 deletion hermes_cli/_subprocess_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,15 @@
from __future__ import annotations

import shutil
import subprocess
import sys
from typing import Sequence
from typing import Any, Sequence

__all__ = [
"IS_WINDOWS",
"resolve_node_command",
"run",
"popen",
"windows_detach_flags",
"windows_detach_flags_without_breakaway",
"windows_hide_flags",
Expand Down Expand Up @@ -201,6 +204,41 @@ def windows_hide_flags() -> int:
return _CREATE_NO_WINDOW


def _hidden_startupinfo() -> Any | None:
"""Return STARTUPINFO that hides a child console on Windows."""
if not IS_WINDOWS:
return None

startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
startupinfo.wShowWindow = 0
return startupinfo


def _with_hidden_windows_defaults(kwargs: dict[str, Any]) -> dict[str, Any]:
"""Apply no-window defaults without changing POSIX behavior."""
if not IS_WINDOWS:
return kwargs

out = dict(kwargs)
creationflags = int(out.get("creationflags", 0) or 0)
if not creationflags & subprocess.CREATE_NEW_CONSOLE:
out["creationflags"] = creationflags | windows_hide_flags()
if out.get("startupinfo") is None:
out["startupinfo"] = _hidden_startupinfo()
return out


def run(*popenargs: Any, **kwargs: Any) -> subprocess.CompletedProcess:
"""Run a subprocess with Hermes' Windows hidden-console defaults."""
return subprocess.run(*popenargs, **_with_hidden_windows_defaults(kwargs))


def popen(*popenargs: Any, **kwargs: Any) -> subprocess.Popen:
"""Open a subprocess with Hermes' Windows hidden-console defaults."""
return subprocess.Popen(*popenargs, **_with_hidden_windows_defaults(kwargs))


def windows_detach_popen_kwargs() -> dict:
"""Return a dict of Popen kwargs that detach a child on Windows and
fall back to the POSIX equivalent (``start_new_session=True``) on
Expand Down
6 changes: 4 additions & 2 deletions hermes_cli/banner.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@
import threading
import time
from pathlib import Path
from typing import TYPE_CHECKING, Dict, List, Optional
from urllib.parse import urlparse

from hermes_cli import _subprocess_compat
from hermes_constants import get_hermes_home
from typing import TYPE_CHECKING, Dict, List, Optional

# rich and prompt_toolkit are imported lazily (inside the functions that use
# them) rather than at module level. Importing this module is on the TUI
Expand Down Expand Up @@ -218,7 +220,7 @@ def _check_via_local_git(repo_dir: Path) -> Optional[int]:
if is_shallow:
fetch_args += ["--depth", "1"]
fetch_args.append("--quiet")
subprocess.run(
_subprocess_compat.run(
fetch_args,
capture_output=True, timeout=10,
cwd=str(repo_dir),
Expand Down
4 changes: 3 additions & 1 deletion hermes_cli/copilot_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
from pathlib import Path
from typing import Optional

from hermes_cli import _subprocess_compat

logger = logging.getLogger(__name__)

# OAuth device code flow constants (same client ID as opencode/Copilot CLI)
Expand Down Expand Up @@ -135,7 +137,7 @@ def _try_gh_cli_token() -> Optional[str]:
if hostname:
cmd += ["--hostname", hostname]
try:
result = subprocess.run(
result = _subprocess_compat.run(
cmd,
capture_output=True,
text=True,
Expand Down
Loading
Loading