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
9 changes: 9 additions & 0 deletions 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,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)
Expand Down
34 changes: 33 additions & 1 deletion tests/hermes_cli/test_copilot_auth.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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()
)
8 changes: 8 additions & 0 deletions tui_gateway/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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()
Expand All @@ -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"):
Expand Down