fix(windows): stop subprocess console-window popups + add CI guard (#53791)

* fix(windows): stop subprocess console-window popups + add CI guard

The single biggest source of Windows 'terminal popup' bug reports was bare
subprocess.run/Popen calls spawning a console window. The compat helpers
(windows_hide_flags / windows_detach_popen_kwargs) already existed but the
footgun checker had no rule to stop new bare calls from reintroducing the flash.

- scripts/check-windows-footguns.py: new AST-based rule flagging subprocess
  calls that can create a new console — output-redirection-aware (capture/
  redirect/check_output exempt) and POSIX-only-program-aware (launchctl/
  systemctl/brew/etc. exempt). Comprehensive on real popups, no annotation
  burden on calls that can't flash.
- Swept all genuine window-spawning sites through windows_hide_flags()/
  windows_detach_popen_kwargs(); marked intentionally-visible launches
  (editor/terminal/foreground re-exec) with '# windows-footgun: ok'.
- tests/scripts/test_windows_footgun_subprocess_rule.py: behavior-contract
  tests + full-repo cleanliness invariant.
- CONTRIBUTING.md: documents the rule + the helper pattern.

* test: accept creationflags kwarg in psutil_android fake_subprocess_run

The Windows no-window sweep added creationflags=windows_hide_flags() to
install_psutil_android.py's subprocess.run call; the test's fake stub had a
fixed (cmd) signature and raised TypeError on the new kwarg.
This commit is contained in:
Teknium 2026-06-27 13:03:51 -07:00 committed by GitHub
parent 3b44a3c8bb
commit ef17cd204d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 445 additions and 34 deletions

View file

@ -0,0 +1,164 @@
"""Tests for the subprocess console-window rule in check-windows-footguns.py.
These assert behavior contracts of the AST rule which call shapes get
flagged and which are correctly exempt NOT a snapshot of how many sites
the repo currently has. The rule's job: flag subprocess calls that can spawn
a NEW Windows console window, ignore the ones that physically cannot.
"""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
import pytest
# The checker lives at scripts/check-windows-footguns.py (hyphenated, not a
# normal importable module name) — load it by path.
_REPO_ROOT = Path(__file__).resolve().parents[2]
_CHECKER_PATH = _REPO_ROOT / "scripts" / "check-windows-footguns.py"
@pytest.fixture(scope="module")
def checker():
spec = importlib.util.spec_from_file_location("_wf_checker", _CHECKER_PATH)
mod = importlib.util.module_from_spec(spec)
# Register before exec so the module's dataclasses can resolve their
# __module__ via sys.modules (dataclasses._is_type looks it up there).
sys.modules["_wf_checker"] = mod
spec.loader.exec_module(mod)
return mod
def _flag(checker, src: str) -> list[int]:
"""Return the line numbers the subprocess rule flags for a source string."""
hits = checker.scan_subprocess_window_footguns(Path("x.py"), src)
return [lineno for (lineno, _line, _fg) in hits]
# --- Calls that SHOULD be flagged (can pop a Windows console) --------------
@pytest.mark.parametrize(
"src",
[
'subprocess.run(["git", "status"])',
'subprocess.Popen(["node", "x.js"])',
'subprocess.call(["npm", "run", "build"])',
'subprocess.check_call(["python", "setup.py"])',
"subprocess.run(cmd)", # dynamic argv, no redirection
'sp.run(["foo"])', # `sp` alias
],
)
def test_flags_bare_window_spawning_calls(checker, src):
assert _flag(checker, src) == [1], src
def test_flags_multiline_call_without_redirection(checker):
src = (
"subprocess.run(\n"
" [npm, 'run', 'build'],\n"
" cwd=desktop_dir,\n"
" check=False,\n"
")\n"
)
assert _flag(checker, src) == [1]
# --- Calls that should NOT be flagged (no new console possible) ------------
@pytest.mark.parametrize(
"src",
[
# output captured / redirected -> inherits parent console, no popup
'subprocess.run(["git", "x"], capture_output=True)',
'subprocess.run(["git", "x"], stdout=subprocess.PIPE)',
'subprocess.run(["git", "x"], stderr=subprocess.DEVNULL)',
# check_output always captures stdout
'subprocess.check_output(["git", "rev-parse", "HEAD"])',
# already managing the console
'subprocess.run(["x"], creationflags=windows_hide_flags())',
# ** spread may carry a helper -> not penalised
"subprocess.Popen(argv, **windows_detach_popen_kwargs())",
"subprocess.run(cmd, **run_kwargs)",
],
)
def test_exempts_window_safe_calls(checker, src):
assert _flag(checker, src) == [], src
@pytest.mark.parametrize(
"src",
[
'subprocess.run(["launchctl", "bootout", target])',
'subprocess.run(["systemctl", "status", svc])',
'subprocess.run(["brew", "install", "espeak-ng"])',
'subprocess.run(["codesign", "--sign", "-", app])',
'subprocess.run(["/usr/bin/sudo", "chmod", "4755", p])', # path-qualified
],
)
def test_exempts_posix_only_programs(checker, src):
"""launchctl/systemctl/brew/etc. don't exist on Windows -> can't pop a
Windows console, so they must not require a creationflag or suppression."""
assert _flag(checker, src) == [], src
def test_inline_suppression_marker(checker):
src = 'subprocess.run(["git", "x"]) # windows-footgun: ok\n'
assert _flag(checker, src) == []
def test_inline_suppression_on_multiline_closing_paren(checker):
src = (
"subprocess.run(\n"
" [npm, 'run', 'build'],\n"
" cwd=d,\n"
") # windows-footgun: ok\n"
)
assert _flag(checker, src) == []
def test_non_subprocess_calls_ignored(checker):
# A .run() on something that isn't the subprocess module is not our concern.
src = "loop.run(coro)\nclient.run()\n"
assert _flag(checker, src) == []
def test_syntax_error_returns_empty(checker):
assert _flag(checker, "def (:\n") == []
def test_repo_is_clean_of_window_footguns(checker):
"""Full-repo invariant: no unsuppressed window-spawning subprocess calls
remain in shippable Python packages. This is the chokepoint the rule
exists to hold."""
roots = [
_REPO_ROOT / d
for d in (
"hermes_cli",
"gateway",
"tools",
"cron",
"agent",
"plugins",
"scripts",
"acp_adapter",
"acp_registry",
)
]
roots = [r for r in roots if r.exists()]
offenders: list[str] = []
for path in checker.iter_files(roots):
if path.suffix not in {".py", ".pyw", ".pyi"}:
continue
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
continue
for lineno, _line, _fg in checker.scan_subprocess_window_footguns(path, text):
offenders.append(f"{path.relative_to(_REPO_ROOT)}:{lineno}")
assert not offenders, "Unsuppressed Windows console footguns:\n" + "\n".join(
offenders
)