fix(windows): stop persistent gateway console after update + close checker variable-argv blind spot

Two distinct Windows console bugs that survived #53791/#53810/#53829:

1. Persistent console after update that kills the gateway when closed.
   _spawn_gateway_restart_watcher rewrote the respawned gateway argv to
   windowless pythonw, but launched the WATCHER process itself with
   sys.executable — the venv console python.exe during `hermes update`.
   uv's venv launcher re-execs the base console interpreter, allocating a
   conhost that DETACHED_PROCESS/CREATE_NO_WINDOW can't suppress; the
   respawned gateway inherits it. Resolve the watcher interpreter to
   pythonw too (no-op on POSIX).

2. The AST footgun checker only resolved a flashing program when argv[0]
   was a string literal, so the update path's `git_cmd = ["git", ...]`
   then `subprocess.run(git_cmd + [...], capture_output=True)` calls were
   invisible and shipped unflagged — one console flash per call. Teach
   _argv_head to resolve variable and concat (BinOp Add) argv via a
   module-wide name->program map. Route the 39 newly-visible calls in
   main.py + 23 across 12 other files through the _subprocess_compat
   chokepoint (or creationflags=windows_hide_flags()).

Also fixes a latent NameError: main.py called _subprocess_compat.run with
no module import in scope — the update path would have crashed on Windows.

Tests: checker 32/32, watcher/restart/detach 5/5. Update suite failures
drop 29->7 (remaining 7 are pre-existing baseline failures).
This commit is contained in:
Teknium 2026-06-27 15:39:17 -07:00
parent 2ecca1e7d3
commit 4c677698ff
16 changed files with 202 additions and 89 deletions

View file

@ -34,6 +34,7 @@ import sys
import threading
from pathlib import Path
from typing import Any, Dict, Optional
from hermes_cli import _subprocess_compat
logger = logging.getLogger("agent.lsp.install")
@ -343,7 +344,7 @@ def _install_pip(pkg: str, bin_name: str) -> Optional[str]:
pip_target.mkdir(parents=True, exist_ok=True)
try:
logger.info("[install] pip install --target %s %s", pip_target, pkg)
proc = subprocess.run(
proc = _subprocess_compat.run(
[sys.executable, "-m", "pip", "install", "--target", str(pip_target), "--quiet", pkg],
check=False,
capture_output=True,

View file

@ -219,7 +219,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),

View file

@ -839,8 +839,26 @@ def _spawn_gateway_restart_watcher(old_pid: int, run_argv: list[str]) -> bool:
respawn_env_literal=respawn_env_literal,
)
# The watcher process itself must ALSO run under the windowless
# interpreter. ``sys.executable`` during ``hermes update`` is the venv's
# console ``python.exe``; uv's venv launcher re-execs the base console
# interpreter, which allocates a conhost that DETACHED_PROCESS /
# CREATE_NO_WINDOW cannot suppress. That persistent console then becomes
# the parent of the respawned gateway — so even though the gateway argv is
# rewritten to pythonw, closing the leftover console window kills the
# gateway. Resolve a windowless pythonw for the watcher too (no-op on
# POSIX, where sys.executable has no console problem).
watcher_python = sys.executable
if sys.platform == "win32":
try:
from hermes_cli.gateway_windows import _resolve_detached_python
watcher_python = _resolve_detached_python(sys.executable)[0]
except Exception:
watcher_python = sys.executable
watcher_argv = [
sys.executable,
watcher_python,
"-c",
watcher,
str(old_pid),

View file

@ -5339,7 +5339,7 @@ def _ensure_git_worktree(repo_root: Path, target: Path, branch_name: str) -> Non
"git", "-C", str(repo_root), "worktree", "add", "-b", branch_name,
str(target), "HEAD",
]
result = subprocess.run(
result = _subprocess_compat.run(
cmd,
capture_output=True,
text=True,

View file

@ -262,6 +262,7 @@ from hermes_cli._subprocess_compat import (
windows_detach_popen_kwargs,
windows_hide_flags,
)
from hermes_cli import _subprocess_compat
from pathlib import Path
from typing import Optional
@ -1160,7 +1161,7 @@ def _probe_container(cmd: list, backend: str, via_sudo: bool = False):
all other exceptions propagate naturally.
"""
try:
return subprocess.run(cmd, capture_output=True, text=True, timeout=15)
return _subprocess_compat.run(cmd, capture_output=True, text=True, timeout=15)
except subprocess.TimeoutExpired:
label = f"sudo {backend}" if via_sudo else backend
print(
@ -4456,7 +4457,7 @@ _UPDATE_CRITICAL_FILES = (
def _capture_head_sha(git_cmd, cwd) -> str | None:
"""Return the current HEAD SHA, or None if it can't be resolved."""
try:
result = subprocess.run(
result = _subprocess_compat.run(
git_cmd + ["rev-parse", "HEAD"],
cwd=cwd,
capture_output=True,
@ -4635,7 +4636,7 @@ def _run_with_idle_timeout(
lock = threading.Lock()
try:
proc = subprocess.Popen(
proc = _subprocess_compat.popen(
cmd,
cwd=cwd,
stdout=subprocess.PIPE,
@ -6224,7 +6225,7 @@ def _update_via_zip(args):
# Some environments lose pip inside the venv; bootstrap it back with
# ensurepip before trying the editable install.
try:
subprocess.run(
_subprocess_compat.run(
pip_cmd + ["--version"],
cwd=PROJECT_ROOT,
check=True,
@ -6291,7 +6292,7 @@ def _update_via_zip(args):
def _stash_local_changes_if_needed(git_cmd: list[str], cwd: Path) -> Optional[str]:
status = subprocess.run(
status = _subprocess_compat.run(
git_cmd + ["status", "--porcelain"],
cwd=cwd,
capture_output=True,
@ -6305,7 +6306,7 @@ def _stash_local_changes_if_needed(git_cmd: list[str], cwd: Path) -> Optional[st
# git stash will fail with "needs merge / could not write index". Clear the
# conflict state with `git reset` so the stash can proceed. Working-tree
# changes are preserved; only the index conflict markers are dropped.
unmerged = subprocess.run(
unmerged = _subprocess_compat.run(
git_cmd + ["ls-files", "--unmerged"],
cwd=cwd,
capture_output=True,
@ -6313,7 +6314,7 @@ def _stash_local_changes_if_needed(git_cmd: list[str], cwd: Path) -> Optional[st
)
if unmerged.stdout.strip():
print("→ Clearing unmerged index entries from a previous conflict...")
subprocess.run(git_cmd + ["reset"], cwd=cwd, capture_output=True)
_subprocess_compat.run(git_cmd + ["reset"], cwd=cwd, capture_output=True)
from datetime import datetime, timezone
@ -6327,7 +6328,7 @@ def _stash_local_changes_if_needed(git_cmd: list[str], cwd: Path) -> Optional[st
check=True,
creationflags=windows_hide_flags(),
)
stash_ref = subprocess.run(
stash_ref = _subprocess_compat.run(
git_cmd + ["rev-parse", "--verify", "refs/stash"],
cwd=cwd,
capture_output=True,
@ -6340,7 +6341,7 @@ def _stash_local_changes_if_needed(git_cmd: list[str], cwd: Path) -> Optional[st
def _resolve_stash_selector(
git_cmd: list[str], cwd: Path, stash_ref: str
) -> Optional[str]:
stash_list = subprocess.run(
stash_list = _subprocess_compat.run(
git_cmd + ["stash", "list", "--format=%gd %H"],
cwd=cwd,
capture_output=True,
@ -6395,7 +6396,7 @@ def _restore_stashed_changes(
return False
print("→ Restoring local changes...")
restore = subprocess.run(
restore = _subprocess_compat.run(
git_cmd + ["stash", "apply", stash_ref],
cwd=cwd,
capture_output=True,
@ -6403,7 +6404,7 @@ def _restore_stashed_changes(
)
# Check for unmerged (conflicted) files — can happen even when returncode is 0
unmerged = subprocess.run(
unmerged = _subprocess_compat.run(
git_cmd + ["diff", "--name-only", "--diff-filter=U"],
cwd=cwd,
capture_output=True,
@ -6431,7 +6432,7 @@ def _restore_stashed_changes(
# Always reset to clean state — leaving conflict markers in source
# files makes hermes completely unrunnable (SyntaxError on import).
# The user's changes are safe in the stash for manual recovery.
subprocess.run(
_subprocess_compat.run(
git_cmd + ["reset", "--hard", "HEAD"],
cwd=cwd,
capture_output=True,
@ -6453,7 +6454,7 @@ def _restore_stashed_changes(
)
_print_stash_cleanup_guidance(stash_ref)
else:
drop = subprocess.run(
drop = _subprocess_compat.run(
git_cmd + ["stash", "drop", stash_selector],
cwd=cwd,
capture_output=True,
@ -6505,7 +6506,7 @@ def _discard_stashed_changes(
_print_stash_cleanup_guidance(stash_ref)
return False
drop = subprocess.run(
drop = _subprocess_compat.run(
git_cmd + ["stash", "drop", stash_selector],
cwd=cwd,
capture_output=True,
@ -6542,7 +6543,7 @@ SKIP_UPSTREAM_PROMPT_FILE = ".skip_upstream_prompt"
def _get_origin_url(git_cmd: list[str], cwd: Path) -> Optional[str]:
"""Get the URL of the origin remote, or None if not set."""
try:
result = subprocess.run(
result = _subprocess_compat.run(
git_cmd + ["remote", "get-url", "origin"],
cwd=cwd,
capture_output=True,
@ -6575,7 +6576,7 @@ def _is_fork(origin_url: Optional[str]) -> bool:
def _has_upstream_remote(git_cmd: list[str], cwd: Path) -> bool:
"""Check if an 'upstream' remote already exists."""
try:
result = subprocess.run(
result = _subprocess_compat.run(
git_cmd + ["remote", "get-url", "upstream"],
cwd=cwd,
capture_output=True,
@ -6589,7 +6590,7 @@ def _has_upstream_remote(git_cmd: list[str], cwd: Path) -> bool:
def _add_upstream_remote(git_cmd: list[str], cwd: Path) -> bool:
"""Add the official repo as the 'upstream' remote. Returns True on success."""
try:
result = subprocess.run(
result = _subprocess_compat.run(
git_cmd + ["remote", "add", "upstream", OFFICIAL_REPO_URL],
cwd=cwd,
capture_output=True,
@ -6603,7 +6604,7 @@ def _add_upstream_remote(git_cmd: list[str], cwd: Path) -> bool:
def _count_commits_between(git_cmd: list[str], cwd: Path, base: str, head: str) -> int:
"""Count commits on `head` that are not on `base`. Returns -1 on error."""
try:
result = subprocess.run(
result = _subprocess_compat.run(
git_cmd + ["rev-list", "--count", f"{base}..{head}"],
cwd=cwd,
capture_output=True,
@ -6639,7 +6640,7 @@ def _sync_fork_with_upstream(git_cmd: list[str], cwd: Path) -> bool:
Returns True if push succeeded, False otherwise.
"""
try:
result = subprocess.run(
result = _subprocess_compat.run(
git_cmd + ["push", "origin", "main", "--force-with-lease"],
cwd=cwd,
capture_output=True,
@ -6702,7 +6703,7 @@ def _sync_with_upstream_if_needed(git_cmd: list[str], cwd: Path) -> None:
print()
print("→ Fetching upstream...")
try:
subprocess.run(
_subprocess_compat.run(
git_cmd + ["fetch", "upstream", "main", "--quiet"],
cwd=cwd,
capture_output=True,
@ -6935,7 +6936,7 @@ def _recover_from_interrupted_install() -> None:
# no pip module at all, and uv may also be gone. ensurepip restores a
# known-good pip so at least the plain-pip path below can proceed.
try:
subprocess.run(
_subprocess_compat.run(
[sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"],
cwd=PROJECT_ROOT,
capture_output=True,
@ -8071,7 +8072,7 @@ def _run_logged_subprocess(cmd, *, cwd=None, env=None):
Returns the ``CompletedProcess`` (with ``stdout`` populated) so the caller
can decide whether to surface the captured output on failure.
"""
result = subprocess.run(
result = _subprocess_compat.run(
cmd,
cwd=cwd,
env=env,
@ -8178,7 +8179,7 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False):
# would then report a huge bogus "behind" number. Detect shallow up front:
# fetch with --depth 1 to preserve the boundary and report presence-only.
is_shallow = (
subprocess.run(
_subprocess_compat.run(
git_cmd + ["rev-parse", "--is-shallow-repository"],
cwd=PROJECT_ROOT,
capture_output=True,
@ -8238,7 +8239,7 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False):
# Without this, `git rev-list HEAD..origin/<bogus> --count` exits 128 and
# (with check=True) raises CalledProcessError, surfacing a Python
# traceback. Friendlier to detect-and-report.
verify_result = subprocess.run(
verify_result = _subprocess_compat.run(
git_cmd + ["rev-parse", "--verify", "--quiet", compare_branch],
cwd=PROJECT_ROOT,
capture_output=True,
@ -8251,11 +8252,11 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False):
if is_shallow:
# No history to count across the shallow boundary. Compare tip SHAs and
# report presence-only (mirrors the banner's _check_via_local_git).
head_sha = subprocess.run(
head_sha = _subprocess_compat.run(
git_cmd + ["rev-parse", "HEAD"],
cwd=PROJECT_ROOT, capture_output=True, text=True,
).stdout.strip()
target_sha = subprocess.run(
target_sha = _subprocess_compat.run(
git_cmd + ["rev-parse", compare_branch],
cwd=PROJECT_ROOT, capture_output=True, text=True,
).stdout.strip()
@ -8268,7 +8269,7 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False):
print(f" Run '{recommended_update_command()}' to install.")
return
rev_result = subprocess.run(
rev_result = _subprocess_compat.run(
git_cmd + ["rev-list", f"HEAD..{compare_branch}", "--count"],
cwd=PROJECT_ROOT,
capture_output=True,
@ -8784,7 +8785,7 @@ def _discard_lockfile_churn(git_cmd, repo_root):
Best-effort; only ever touches files named ``package-lock.json``.
"""
try:
diff = subprocess.run(
diff = _subprocess_compat.run(
git_cmd + ["diff", "--name-only"],
cwd=repo_root,
capture_output=True,
@ -8805,7 +8806,7 @@ def _discard_lockfile_churn(git_cmd, repo_root):
]
if not dirty:
return
subprocess.run(
_subprocess_compat.run(
git_cmd + ["checkout", "--", *dirty],
cwd=repo_root,
capture_output=True,
@ -9071,7 +9072,7 @@ def _cmd_update_impl(args, gateway_mode: bool):
branch = _resolve_update_branch(args)
print("→ Fetching updates...")
fetch_result = subprocess.run(
fetch_result = _subprocess_compat.run(
git_cmd + ["fetch", "origin", branch],
cwd=PROJECT_ROOT,
capture_output=True,
@ -9095,7 +9096,7 @@ def _cmd_update_impl(args, gateway_mode: bool):
sys.exit(1)
# Get current branch (returns literal "HEAD" when detached)
result = subprocess.run(
result = _subprocess_compat.run(
git_cmd + ["rev-parse", "--abbrev-ref", "HEAD"],
cwd=PROJECT_ROOT,
capture_output=True,
@ -9118,7 +9119,7 @@ def _cmd_update_impl(args, gateway_mode: bool):
print(f" ⚠ Currently on {label} — switching to {branch} for update...")
# Stash before checkout so uncommitted work isn't lost
auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT)
checkout_result = subprocess.run(
checkout_result = _subprocess_compat.run(
git_cmd + ["checkout", branch],
cwd=PROJECT_ROOT,
capture_output=True,
@ -9129,7 +9130,7 @@ def _cmd_update_impl(args, gateway_mode: bool):
# it up as a tracking branch of origin/<branch>. This is
# the common case when the requested branch exists upstream
# but was never checked out locally.
track_result = subprocess.run(
track_result = _subprocess_compat.run(
git_cmd + ["checkout", "-B", branch, f"origin/{branch}"],
cwd=PROJECT_ROOT,
capture_output=True,
@ -9160,7 +9161,7 @@ def _cmd_update_impl(args, gateway_mode: bool):
)
# Check if there are updates
result = subprocess.run(
result = _subprocess_compat.run(
git_cmd + ["rev-list", f"HEAD..origin/{branch}", "--count"],
cwd=PROJECT_ROOT,
capture_output=True,
@ -9186,7 +9187,7 @@ def _cmd_update_impl(args, gateway_mode: bool):
input_fn=gw_input_fn,
)
if current_branch not in {branch, "HEAD"}:
subprocess.run(
_subprocess_compat.run(
git_cmd + ["checkout", current_branch],
cwd=PROJECT_ROOT,
capture_output=True,
@ -9225,7 +9226,7 @@ def _cmd_update_impl(args, gateway_mode: bool):
# the bad commit and the fix landing).
pre_pull_sha = _capture_head_sha(git_cmd, PROJECT_ROOT)
try:
pull_result = subprocess.run(
pull_result = _subprocess_compat.run(
git_cmd + ["pull", "--ff-only", "origin", branch],
cwd=PROJECT_ROOT,
capture_output=True,
@ -9238,7 +9239,7 @@ def _cmd_update_impl(args, gateway_mode: bool):
print(
" ⚠ Fast-forward not possible (history diverged), resetting to match remote..."
)
reset_result = subprocess.run(
reset_result = _subprocess_compat.run(
git_cmd + ["reset", "--hard", f"origin/{branch}"],
cwd=PROJECT_ROOT,
capture_output=True,
@ -9274,7 +9275,7 @@ def _cmd_update_impl(args, gateway_mode: bool):
if pre_pull_sha:
print()
print(f"→ Rolling back to {pre_pull_sha[:10]}...")
rollback_result = subprocess.run(
rollback_result = _subprocess_compat.run(
git_cmd + ["reset", "--hard", pre_pull_sha],
cwd=PROJECT_ROOT,
capture_output=True,
@ -9380,7 +9381,7 @@ def _cmd_update_impl(args, gateway_mode: bool):
# ensurepip before trying the editable install.
pip_cmd = [sys.executable, "-m", "pip"]
try:
subprocess.run(
_subprocess_compat.run(
pip_cmd + ["--version"],
cwd=PROJECT_ROOT,
check=True,

View file

@ -32,6 +32,7 @@ from pathlib import Path, PurePosixPath, PureWindowsPath
from typing import List, Optional, Tuple
from agent.skill_utils import is_excluded_skill_path
from hermes_cli import _subprocess_compat
_PROFILE_ID_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
@ -1058,7 +1059,7 @@ def seed_profile_skills(profile_dir: Path, quiet: bool = False) -> Optional[dict
}
project_root = Path(__file__).parent.parent.resolve()
try:
result = subprocess.run(
result = _subprocess_compat.run(
[sys.executable, "-c",
"import json; from tools.skills_sync import sync_skills; "
"r = sync_skills(quiet=True); print(json.dumps(r))"],

View file

@ -161,6 +161,7 @@ from hermes_cli.cli_output import ( # noqa: E402
)
from hermes_cli.secret_prompt import masked_secret_prompt # noqa: E402
from hermes_cli._subprocess_compat import windows_hide_flags
from hermes_cli import _subprocess_compat
def is_interactive_stdin() -> bool:
@ -1316,7 +1317,7 @@ def setup_terminal_backend(config: dict):
text=True,
)
else:
result = subprocess.run(
result = _subprocess_compat.run(
[sys.executable, "-m", "pip", "install", "modal"],
capture_output=True,
text=True,
@ -1369,7 +1370,7 @@ def setup_terminal_backend(config: dict):
text=True,
)
else:
result = subprocess.run(
result = _subprocess_compat.run(
[sys.executable, "-m", "pip", "install", "daytona"],
capture_output=True,
text=True,

View file

@ -229,6 +229,7 @@ def _checklist_toolset_keys(platform: str) -> Set[str]:
# compatibility with existing ``PLATFORMS[key]["label"]`` access patterns.
from hermes_cli.platforms import PLATFORMS as _PLATFORMS_REGISTRY
from hermes_cli._subprocess_compat import windows_hide_flags
from hermes_cli import _subprocess_compat
PLATFORMS = {
k: {"label": info.label, "default_toolset": info.default_toolset}
@ -653,7 +654,7 @@ def _pip_install(
pip_cmd = [sys.executable, "-m", "pip"]
try:
# Probe for pip; bootstrap via ensurepip if missing (uv venv lacks it).
probe = subprocess.run(
probe = _subprocess_compat.run(
pip_cmd + ["--version"],
capture_output=True, text=True, timeout=15,
)
@ -661,7 +662,7 @@ def _pip_install(
raise FileNotFoundError("pip not in venv")
except (subprocess.TimeoutExpired, FileNotFoundError):
try:
subprocess.run(
_subprocess_compat.run(
[sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"],
capture_output=True, text=True, timeout=120, check=True,
)
@ -672,7 +673,7 @@ def _pip_install(
stderr=f"pip not available and ensurepip failed: {e}",
)
return subprocess.run(
return _subprocess_compat.run(
pip_cmd + ["install", *args],
capture_output=capture_output, text=True, timeout=timeout,
)
@ -889,7 +890,7 @@ def _run_cua_driver_installer(label: str = "Installing", verbose: bool = True) -
if verbose:
result = subprocess.run(install_cmd, shell=use_shell, timeout=300, env=_cua_driver_env(), creationflags=windows_hide_flags())
else:
result = subprocess.run(
result = _subprocess_compat.run(
install_cmd, shell=use_shell, timeout=300, env=_cua_driver_env(),
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, encoding="utf-8", errors="replace",
@ -1031,7 +1032,7 @@ def _run_post_setup(post_setup_key: str):
else [npx_bin, "-y", "agent-browser", "install", "--with-deps"]
)
try:
result = subprocess.run(
result = _subprocess_compat.run(
install_cmd,
capture_output=True, text=True, cwd=str(PROJECT_ROOT), timeout=600,
)

View file

@ -21,6 +21,7 @@ from pathlib import Path
from typing import Any, Dict, Optional
from hermes_constants import get_hermes_home
from hermes_cli import _subprocess_compat
# File + directory layout (under $HERMES_HOME):
#
@ -160,7 +161,7 @@ def start(
# signals don't propagate.
log_fh = open(log_path, "ab", buffering=0)
try:
proc = subprocess.Popen(
proc = _subprocess_compat.popen(
[sys.executable, "-m", "plugins.google_meet.meet_bot"],
stdin=subprocess.DEVNULL,
stdout=log_fh,

View file

@ -13,6 +13,7 @@ from pathlib import Path
from hermes_constants import get_hermes_home
from plugins.memory.honcho.client import _host_block, profile_host_key, resolve_active_host, resolve_config_path, HOST
from hermes_cli.config import cfg_get
from hermes_cli import _subprocess_compat
def clone_honcho_for_profile(profile_name: str) -> bool:
@ -517,7 +518,7 @@ def _ensure_sdk_installed() -> bool:
import subprocess
print(" Installing honcho-ai...", flush=True)
result = subprocess.run(
result = _subprocess_compat.run(
[sys.executable, "-m", "pip", "install", "honcho-ai>=2.0.1"],
capture_output=True,
text=True,

View file

@ -92,6 +92,7 @@ except (ModuleNotFoundError, ImportError):
return str(home)
from utils import atomic_replace
from hermes_cli._subprocess_compat import windows_hide_flags
def _hermes_home() -> Path:
@ -382,6 +383,7 @@ def install_deps() -> bool:
subprocess.check_call(
[sys.executable, "-m", "pip", "install", "--quiet"] + _REQUIRED_PACKAGES,
stdout=subprocess.DEVNULL,
creationflags=windows_hide_flags(),
)
print("Dependencies installed.")
return True

View file

@ -463,7 +463,9 @@ def _call_attr_name(node: ast.Call) -> str | None:
return f.attr
def _suppresses_window(node: ast.Call, func_name: str) -> bool:
def _suppresses_window(
node: ast.Call, func_name: str, var_progs: dict[str, str] | None = None
) -> bool:
"""True if this subprocess call cannot create a new console window.
The honest invariant (corrected after review of PR #53791): capturing or
@ -492,11 +494,11 @@ def _suppresses_window(node: ast.Call, func_name: str) -> bool:
return True
if any(kw.arg is None for kw in node.keywords): # **kwargs spread
return True
if _is_posix_only_program(node):
if _is_posix_only_program(node, var_progs):
return True
# Capture/redirect is only a safety boundary for programs that don't
# allocate a Windows console — NOT for git/npm/node/python/ffmpeg/etc.
if not _is_windows_flashing_program(node):
if not _is_windows_flashing_program(node, var_progs):
if func_name == "check_output":
return True
if explicit & {"stdout", "stderr", "capture_output"}:
@ -504,41 +506,120 @@ def _suppresses_window(node: ast.Call, func_name: str) -> bool:
return False
def _argv_head(node: ast.Call) -> str | None:
"""Return the path-stripped first argv element if it's a string literal."""
if not node.args:
def _head_of_list_literal(seq) -> str | None:
"""Path-stripped first element of a list/tuple literal, if a str constant.
Also recognises ``[sys.executable, ...]`` and ``[sys.executable, "-m", …]``
as the ``python`` program — the launcher interpreter flashes a console just
like a bare ``python`` does.
"""
if not isinstance(seq, (ast.List, ast.Tuple)) or not seq.elts:
return None
first = node.args[0]
if isinstance(first, (ast.List, ast.Tuple)) and first.elts:
head = first.elts[0]
if isinstance(head, ast.Constant) and isinstance(head.value, str):
return head.value.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
head = seq.elts[0]
if isinstance(head, ast.Constant) and isinstance(head.value, str):
return head.value.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
# sys.executable -> the running Python interpreter (a flashing console exe).
if (
isinstance(head, ast.Attribute)
and head.attr == "executable"
and isinstance(head.value, ast.Name)
and head.value.id == "sys"
):
return "python"
return None
def _is_windows_flashing_program(node: ast.Call) -> bool:
def _argv_head(node: ast.Call, var_progs: dict[str, str] | None = None) -> str | None:
"""Return the path-stripped program name of the call's argv, or None.
Resolves three shapes so the rule sees through the indirection that real
update/build code uses (this was the blind spot behind the recurring
Windows flash storm — ``git_cmd = ["git", "-c", …]`` then
``subprocess.run(git_cmd + ["fetch"], capture_output=True)`` was invisible):
* literal: ``subprocess.run(["git", "status"])``
* variable: ``cmd = ["git", …]; subprocess.run(cmd, …)``
* concat: ``subprocess.run(git_cmd + ["fetch"], …)`` (BinOp Add)
``var_progs`` maps a variable name to the program head inferred from its
nearest ``name = [<literal>, …]`` assignment in the same module. Dynamic
argv that can't be resolved still returns None (treated as unknown ->
flagged when not otherwise window-safe).
"""
if not node.args:
return None
first = node.args[0]
# literal list/tuple
lit = _head_of_list_literal(first)
if lit is not None:
return lit
# bare variable: cmd
if isinstance(first, ast.Name) and var_progs:
return var_progs.get(first.id)
# concat: <var> + [...] or [...] + <var>
if isinstance(first, ast.BinOp) and isinstance(first.op, ast.Add):
left = first.left
if isinstance(left, ast.Name) and var_progs:
return var_progs.get(left.id)
lit_left = _head_of_list_literal(left)
if lit_left is not None:
return lit_left
return None
def _build_var_progs(tree: ast.AST) -> dict[str, str]:
"""Map variable names to the program head of the list literal assigned to
them, e.g. ``git_cmd = ["git", "-c", …]`` -> ``{"git_cmd": "git"}``.
A name assigned more than once with conflicting heads is dropped (we can't
prove which value reaches the call site, so we don't claim to know it).
Scans the whole module — call sites and their ``name = [...]`` definitions
are routinely in different functions, and the names used for command lists
(``git_cmd``, ``pip_cmd``, ``npm``…) don't collide across scopes in
practice. False positives here only ever ADD a flag, which an inline
``# windows-footgun: ok`` can suppress.
"""
progs: dict[str, str] = {}
conflicted: set[str] = set()
for node in ast.walk(tree):
if not isinstance(node, ast.Assign):
continue
head = _head_of_list_literal(node.value)
if head is None:
continue
for tgt in node.targets:
if not isinstance(tgt, ast.Name):
continue
if tgt.id in conflicted:
continue
if tgt.id in progs and progs[tgt.id] != head:
# Conflicting definitions — give up on this name.
del progs[tgt.id]
conflicted.add(tgt.id)
continue
progs[tgt.id] = head
return progs
def _is_windows_flashing_program(
node: ast.Call, var_progs: dict[str, str] | None = None
) -> bool:
"""True if the call's program is a known cross-platform console exe that
allocates a Windows console window (so capture is NOT a safe boundary)."""
prog = _argv_head(node)
prog = _argv_head(node, var_progs)
return prog is not None and prog in _WINDOWS_FLASHING_PROGRAMS
def _is_posix_only_program(node: ast.Call) -> bool:
def _is_posix_only_program(
node: ast.Call, var_progs: dict[str, str] | None = None
) -> bool:
"""True if the call's program is a statically-known POSIX-only executable.
Only inspects a literal list/tuple first arg whose first element is a
string constant (e.g. ``["launchctl", "bootout", target]``). Dynamic
argv (variables, f-strings) is treated as unknown and still flagged.
Resolves literal, variable, and concat argv shapes (see :func:`_argv_head`).
Dynamic argv that can't be resolved is treated as unknown and still flagged.
"""
if not node.args:
return False
first = node.args[0]
if isinstance(first, (ast.List, ast.Tuple)) and first.elts:
head = first.elts[0]
if isinstance(head, ast.Constant) and isinstance(head.value, str):
prog = head.value.rsplit("/", 1)[-1]
return prog in _POSIX_ONLY_PROGRAMS
return False
prog = _argv_head(node, var_progs)
return prog is not None and prog in _POSIX_ONLY_PROGRAMS
def scan_subprocess_window_footguns(
@ -555,6 +636,7 @@ def scan_subprocess_window_footguns(
except SyntaxError:
return []
lines = text.splitlines()
var_progs = _build_var_progs(tree)
rule = Footgun(
name=SUBPROCESS_FOOTGUN_NAME,
pattern=re.compile(r"^$"), # unused; AST-driven
@ -568,7 +650,7 @@ def scan_subprocess_window_footguns(
func_name = _call_attr_name(node)
if func_name is None:
continue
if _suppresses_window(node, func_name):
if _suppresses_window(node, func_name, var_progs):
continue
lineno = node.lineno
line = lines[lineno - 1] if 0 <= lineno - 1 < len(lines) else ""

View file

@ -30,6 +30,7 @@ SCRIPT_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(SCRIPT_DIR))
from release import resolve_author # noqa: E402
from hermes_cli import _subprocess_compat
REPO_ROOT = SCRIPT_DIR.parent
@ -78,7 +79,7 @@ def is_ignored(handle: str, email: str = "") -> bool:
def git(*args, cwd=None):
"""Run a git command and return stdout."""
result = subprocess.run(
result = _subprocess_compat.run(
["git"] + list(args),
capture_output=True,
text=True,

View file

@ -29,6 +29,7 @@ import sys
from collections import defaultdict
from datetime import datetime
from pathlib import Path
from hermes_cli import _subprocess_compat
REPO_ROOT = Path(__file__).resolve().parent.parent
VERSION_FILE = REPO_ROOT / "hermes_cli" / "__init__.py"
@ -1711,7 +1712,7 @@ AUTHOR_MAP = {
def git(*args, cwd=None):
"""Run a git command and return stdout."""
result = subprocess.run(
result = _subprocess_compat.run(
["git"] + list(args),
capture_output=True, text=True,
cwd=cwd or str(REPO_ROOT),
@ -1724,7 +1725,7 @@ def git(*args, cwd=None):
def git_result(*args, cwd=None):
"""Run a git command and return the full CompletedProcess."""
return subprocess.run(
return _subprocess_compat.run(
["git"] + list(args),
capture_output=True,
text=True,
@ -1862,7 +1863,7 @@ def build_release_artifacts(semver: str) -> list[Path]:
else:
cmd = [sys.executable, "-m", "build", "--sdist", "--wheel"]
result = subprocess.run(
result = _subprocess_compat.run(
cmd,
cwd=str(REPO_ROOT),
capture_output=True,
@ -2280,7 +2281,7 @@ def main():
gh_bin = shutil.which("gh")
if gh_bin:
result = subprocess.run(
result = _subprocess_compat.run(
gh_cmd,
capture_output=True, text=True,
cwd=str(REPO_ROOT),

View file

@ -47,6 +47,7 @@ import time
from concurrent.futures import ThreadPoolExecutor, Future
from pathlib import Path
from typing import Dict, List, Tuple
from hermes_cli import _subprocess_compat
# Default test discovery roots.
@ -243,7 +244,7 @@ def _run_one_file(
subproc_start = time.monotonic()
# launch the pytest process
proc = subprocess.Popen(
proc = _subprocess_compat.popen(
cmd,
cwd=repo_root,
stdout=subprocess.PIPE,

View file

@ -78,6 +78,7 @@ import sysconfig
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Optional
from hermes_cli import _subprocess_compat
logger = logging.getLogger(__name__)
@ -635,7 +636,7 @@ def _venv_pip_install(specs: tuple[str, ...], *, timeout: int = 300) -> _Install
# Tier 2: python -m pip (with ensurepip bootstrap if needed)
pip_cmd = [sys.executable, "-m", "pip"]
try:
probe = subprocess.run(
probe = _subprocess_compat.run(
pip_cmd + ["--version"],
capture_output=True, text=True, timeout=15,
stdin=subprocess.DEVNULL,
@ -644,7 +645,7 @@ def _venv_pip_install(specs: tuple[str, ...], *, timeout: int = 300) -> _Install
raise FileNotFoundError("pip not in venv")
except (subprocess.TimeoutExpired, FileNotFoundError):
try:
subprocess.run(
_subprocess_compat.run(
[sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"],
capture_output=True, text=True, timeout=120, check=True,
stdin=subprocess.DEVNULL,
@ -654,7 +655,7 @@ def _venv_pip_install(specs: tuple[str, ...], *, timeout: int = 300) -> _Install
f"pip not available and ensurepip failed: {e}")
try:
r = subprocess.run(
r = _subprocess_compat.run(
pip_cmd + ["install", *target_args, *constraint_args, *specs],
capture_output=True, text=True, timeout=timeout,
stdin=subprocess.DEVNULL,