mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-01 12:02:05 +00:00
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:
parent
3b44a3c8bb
commit
ef17cd204d
22 changed files with 445 additions and 34 deletions
164
tests/scripts/test_windows_footgun_subprocess_rule.py
Normal file
164
tests/scripts/test_windows_footgun_subprocess_rule.py
Normal 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
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue