diff --git a/hermes_cli/copilot_auth.py b/hermes_cli/copilot_auth.py index e6f63a1557c9..195c12f6e52e 100644 --- a/hermes_cli/copilot_auth.py +++ b/hermes_cli/copilot_auth.py @@ -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) @@ -135,12 +137,19 @@ def _try_gh_cli_token() -> Optional[str]: if hostname: cmd += ["--hostname", hostname] try: + # gh runs from the windowless desktop gateway (pythonw.exe), where a + # captured console child still allocates — and flashes — a console + # window. windows_hide_flags() is CREATE_NO_WINDOW on win32, 0 on + # POSIX. (The #53810 `_subprocess_compat.run` chokepoint was rolled + # back in the #53853 revert, so this uses the surviving helper + # directly.) See #52310. result = subprocess.run( cmd, capture_output=True, text=True, timeout=5, env=clean_env, + creationflags=_subprocess_compat.windows_hide_flags(), ) except (FileNotFoundError, subprocess.TimeoutExpired) as exc: logger.debug("gh CLI token lookup failed (%s): %s", gh_path, exc) diff --git a/tests/hermes_cli/test_copilot_auth.py b/tests/hermes_cli/test_copilot_auth.py index 3d0b0bdeb722..bcdc1c2662da 100644 --- a/tests/hermes_cli/test_copilot_auth.py +++ b/tests/hermes_cli/test_copilot_auth.py @@ -1,7 +1,7 @@ """Tests for hermes_cli.copilot_auth — Copilot token validation and resolution.""" import pytest -from unittest.mock import patch +from unittest.mock import patch, MagicMock class TestTokenValidation: @@ -199,3 +199,35 @@ def test_copilot_env_vars_order_matches_docs(self): assert copilot.api_key_env_vars == ( "COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN" ) + + +class TestGhCliTokenHidesConsole: + """The `gh auth token` fallback must pass CREATE_NO_WINDOW so it doesn't + flash a console window when spawned from the windowless desktop gateway + (pythonw.exe). The footgun checker can't guard this site — the argv (`cmd`) + is a variable, not a literal, so its console-spawn rule can't see it's `gh` + — so this test is the only regression guard. See #52310. + """ + + def test_try_gh_cli_token_passes_no_window_flag(self, monkeypatch): + import subprocess + from hermes_cli import copilot_auth + + called = {} + + def fake_run(cmd, **kwargs): + called["cmd"] = cmd + called["kwargs"] = kwargs + return MagicMock(returncode=0, stdout="gho_token_from_gh\n") + + monkeypatch.setattr(subprocess, "run", fake_run) + monkeypatch.setattr(copilot_auth, "_gh_cli_candidates", lambda: ["gh"]) + + token = copilot_auth._try_gh_cli_token() + + assert token == "gho_token_from_gh" + assert called["cmd"][:3] == ["gh", "auth", "token"] + assert ( + called["kwargs"]["creationflags"] + == copilot_auth._subprocess_compat.windows_hide_flags() + ) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index ec61aed6d57e..6fea17a0a77f 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -24,6 +24,7 @@ set_hermes_home_override, ) from hermes_cli.env_loader import load_hermes_dotenv +from hermes_cli import _subprocess_compat from utils import is_truthy_value from tui_gateway import git_probe from tui_gateway.transport import ( @@ -11619,12 +11620,18 @@ def _list_repo_files(root: str) -> list[str]: files: list[str] = [] try: + # This git probe runs from the windowless desktop gateway (pythonw.exe), + # where capturing output does NOT stop a new console from being + # allocated (and flashing). windows_hide_flags() = CREATE_NO_WINDOW on + # win32, 0 on POSIX. (#53810's chokepoint was reverted in #53853, so we + # use the surviving helper directly.) See #52310. top_result = subprocess.run( ["git", "-C", root, "rev-parse", "--show-toplevel"], capture_output=True, timeout=2.0, check=False, stdin=subprocess.DEVNULL, + creationflags=_subprocess_compat.windows_hide_flags(), ) if top_result.returncode == 0: top = top_result.stdout.decode("utf-8", "replace").strip() @@ -11643,6 +11650,7 @@ def _list_repo_files(root: str) -> list[str]: timeout=2.0, check=False, stdin=subprocess.DEVNULL, + creationflags=_subprocess_compat.windows_hide_flags(), ) if list_result.returncode == 0: for p in list_result.stdout.decode("utf-8", "replace").split("\0"):