From a0730afc049987339ec0e1594f07a8bc42735cba Mon Sep 17 00:00:00 2001 From: Lukas Geiger Date: Thu, 22 Oct 2026 23:33:56 +0200 Subject: [PATCH] fix(windows): hide console children at every CLI boundary Generated-with: Codex; session 01a164da-cc28-7140-b499-d40dae7c2e11 --- CHANGELOG.md | 8 ++++ src/githubbot_core/credentials.py | 1 + src/githubbot_core/multibot_probe.py | 10 ++++- src/githubbot_core/review_gate.py | 3 ++ src/githubbot_core/transport.py | 1 + src/githubbot_core/typed_actions.py | 9 +++- tests/test_consumer_install.py | 11 ++++- tests/test_multibot_windows.py | 2 + tests/test_review_gate.py | 8 +++- tests/test_windowless_processes.py | 64 ++++++++++++++++++++++++++++ 10 files changed, 113 insertions(+), 4 deletions(-) create mode 100644 tests/test_windowless_processes.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a70a45..785036c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ Format: [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), [Semantic Vers ## [Unreleased] +### Behoben / Fixed + +- Windows-Konsolenstarts für GitHub-Abfragen, Credential-Lookup, Git-Push und + SSH-Reviewbelege verwenden `CREATE_NO_WINDOW`, auch unter einem konsolenlosen + Elternprozess. Testhelfer schützen ihre eigenen Kindstarts ebenso. Auf POSIX + bleibt das Verhalten unverändert. / Hide console children at every explicit + CLI boundary, including test helpers; POSIX behavior is unchanged. + ### Hinzugefügt / Added - Getypte Schreibaktionen `pr.merge`, `pr.ready`, `actions.dispatch`, `actions.rerun`, `pr.create` diff --git a/src/githubbot_core/credentials.py b/src/githubbot_core/credentials.py index 6ab7f1d..02fff74 100644 --- a/src/githubbot_core/credentials.py +++ b/src/githubbot_core/credentials.py @@ -58,6 +58,7 @@ def token(self) -> Optional[str]: encoding="utf-8", errors="replace", timeout=15, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), ) except (OSError, subprocess.TimeoutExpired): return None diff --git a/src/githubbot_core/multibot_probe.py b/src/githubbot_core/multibot_probe.py index df2756f..bf6b12b 100644 --- a/src/githubbot_core/multibot_probe.py +++ b/src/githubbot_core/multibot_probe.py @@ -13,7 +13,15 @@ def query(args, *, run=subprocess.run): - result = run(["gh", *args], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=90) + result = run( + ["gh", *args], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=90, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) if result.returncode: # Do not forward arbitrary CLI stderr (may contain credential configuration). return {"ok": False, "exit_code": result.returncode, "error": "GitHub-Abfrage fehlgeschlagen"} diff --git a/src/githubbot_core/review_gate.py b/src/githubbot_core/review_gate.py index 5a512de..43a1db2 100644 --- a/src/githubbot_core/review_gate.py +++ b/src/githubbot_core/review_gate.py @@ -358,6 +358,7 @@ def verify_ssh_signature( input=receipt_bytes, capture_output=True, timeout=20, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), ) if result.returncode != 0: detail = result.stderr.decode("utf-8", errors="replace").strip() @@ -395,6 +396,7 @@ def sign_receipt(receipt_path: Path, key_path: Path, output_path: Path, namespac encoding="utf-8", errors="replace", timeout=20, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), ) if result.returncode != 0: raise ReceiptValidationError(f"receipt signing failed: {result.stderr.strip() or result.stdout.strip()}") @@ -542,6 +544,7 @@ def _resolve_token() -> str: encoding="utf-8", errors="replace", timeout=15, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), ) if result.returncode != 0 or not result.stdout.strip(): raise ReceiptValidationError("no GitHub token available") diff --git a/src/githubbot_core/transport.py b/src/githubbot_core/transport.py index 937d319..7e46901 100644 --- a/src/githubbot_core/transport.py +++ b/src/githubbot_core/transport.py @@ -51,6 +51,7 @@ def _invoke(self, args: list[str], stdin: Optional[str] = None) -> tuple[str, Op encoding="utf-8", errors="replace", timeout=self.timeout, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), ) except subprocess.TimeoutExpired: return "", "Timeout" diff --git a/src/githubbot_core/typed_actions.py b/src/githubbot_core/typed_actions.py index 92fc00d..5c42c79 100644 --- a/src/githubbot_core/typed_actions.py +++ b/src/githubbot_core/typed_actions.py @@ -240,7 +240,14 @@ def exec_create_pr(transport, params: dict, config: dict): def _run_git(path: str, *args: str) -> tuple[str, Optional[str]]: - result = _git(["git", "-C", path, *args], capture_output=True, text=True, encoding="utf-8", errors="replace") + result = _git( + ["git", "-C", path, *args], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) if result.returncode: return "", " ".join((result.stderr or result.stdout or "").split())[:500] or f"git exit {result.returncode}" return result.stdout.strip(), None diff --git a/tests/test_consumer_install.py b/tests/test_consumer_install.py index bc7ee4d..18b33d3 100644 --- a/tests/test_consumer_install.py +++ b/tests/test_consumer_install.py @@ -26,7 +26,16 @@ def _run(cmd, cwd, **kwargs): env = {**os.environ, "PYTHONIOENCODING": "utf-8"} env.pop("PYTHONPATH", None) - return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, encoding="utf-8", env=env, **kwargs) + return subprocess.run( + cmd, + cwd=cwd, + capture_output=True, + text=True, + encoding="utf-8", + env=env, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + **kwargs, + ) @pytest.mark.parametrize("artifact_variable", ["GITHUBBOT_CORE_TEST_WHEEL", "GITHUBBOT_CORE_TEST_SDIST"]) diff --git a/tests/test_multibot_windows.py b/tests/test_multibot_windows.py index bd5dd32..018447a 100644 --- a/tests/test_multibot_windows.py +++ b/tests/test_multibot_windows.py @@ -36,6 +36,7 @@ def test_success_and_failure_wait_for_key_then_keep_exitcode(tmp_path, code): stderr=subprocess.PIPE, text=True, encoding="utf-8", + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), ) try: time.sleep(1) @@ -65,5 +66,6 @@ def test_explicit_dry_or_no_pause_skips_wait(tmp_path, opt): text=True, encoding="utf-8", timeout=10, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), ) assert result.returncode == 23 and "[TEST]" in result.stdout and not result.stderr diff --git a/tests/test_review_gate.py b/tests/test_review_gate.py index 4409148..691ef64 100644 --- a/tests/test_review_gate.py +++ b/tests/test_review_gate.py @@ -129,12 +129,14 @@ def test_real_ssh_signature_is_bound_to_principal_and_receipt(tmp_path: Path) -> subprocess.run( ["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(key)], check=True, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), ) receipt_path = tmp_path / "receipt.json" receipt_path.write_bytes(canonical_receipt_bytes(_receipt())) subprocess.run( ["ssh-keygen", "-Y", "sign", "-f", str(key), "-n", "githubbot-review-gate", str(receipt_path)], check=True, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), ) allowed = tmp_path / "allowed_signers" public_key = key.with_suffix(".pub").read_text(encoding="utf-8").strip() @@ -335,7 +337,11 @@ def test_cli_create_writes_a_canonical_receipt(tmp_path: Path) -> None: @pytest.mark.skipif(shutil.which("ssh-keygen") is None, reason="OpenSSH ssh-keygen is unavailable") def test_cli_sign_writes_only_the_requested_signature(tmp_path: Path) -> None: key = tmp_path / "reviewer" - subprocess.run(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(key)], check=True) + subprocess.run( + ["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(key)], + check=True, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) receipt = tmp_path / "receipt.json" current = datetime.now(timezone.utc).replace(microsecond=0) current_receipt = _receipt( diff --git a/tests/test_windowless_processes.py b/tests/test_windowless_processes.py new file mode 100644 index 0000000..3631a7f --- /dev/null +++ b/tests/test_windowless_processes.py @@ -0,0 +1,64 @@ +"""All explicit console starts stay hidden, including CLI and signing adapters.""" + +from datetime import datetime, timedelta, timezone +from pathlib import Path +import subprocess +from types import SimpleNamespace + +import pytest + +from githubbot_core import credentials, multibot_probe, review_gate, transport, typed_actions + + +@pytest.mark.parametrize("windows_flag", [None, 0x08000000], ids=["posix", "windows"]) +def test_every_console_boundary_preserves_windowless_flags(monkeypatch, tmp_path, windows_flag): + calls = [] + + def run(command, **kwargs): + calls.append((command, kwargs)) + if "-Y" in command and "sign" in command: + Path(command[-1] + ".sig").write_bytes(b"synthetic-signature") + text = kwargs.get("text", False) + return subprocess.CompletedProcess(command, 0, "{}" if text else b"", "" if text else b"") + + adapter = SimpleNamespace(run=run, TimeoutExpired=subprocess.TimeoutExpired) + if windows_flag is not None: + adapter.CREATE_NO_WINDOW = windows_flag + for module in (credentials, multibot_probe, review_gate, transport, typed_actions): + monkeypatch.setattr(module, "subprocess", adapter) + monkeypatch.setattr(typed_actions, "_git", run) + monkeypatch.setattr(review_gate.shutil, "which", lambda _: "ssh-keygen") + monkeypatch.delenv("GH_TOKEN", raising=False) + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + + assert transport.GhCliTransport(runner=run).get("repos/example/repo") == ({}, None) + assert credentials.GhCliTokenProvider(runner=run).token() == "{}" + assert multibot_probe.query(["api", "user"], run=run)["ok"] is True + assert typed_actions._run_git(str(tmp_path), "status") == ("{}", None) + review_gate.verify_ssh_signature(b"{}", b"synthetic-signature", b"synthetic-signers", principal="reviewer") + assert review_gate._resolve_token() == "{}" + + now = datetime.now(timezone.utc) + receipt = review_gate.create_receipt( + kind="model_review", + repository="example/repo", + pr_number=1, + head_sha="a" * 40, + verdict="approve", + reviewer="synthetic-reviewer", + artifact_sha256="b" * 64, + findings={"critical": 0, "high": 0, "medium": 0, "low": 0}, + checks=["synthetic-only"], + issued_at=(now - timedelta(minutes=1)).strftime("%Y-%m-%dT%H:%M:%SZ"), + valid_until=(now + timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%SZ"), + nonce="windowless-synthetic-0001", + ) + receipt_path, key_path, output_path = (tmp_path / name for name in ("receipt.json", "key", "signature")) + receipt_path.write_bytes(review_gate.canonical_receipt_bytes(receipt)) + key_path.write_bytes(b"synthetic-key") + review_gate.sign_receipt(receipt_path, key_path, output_path) + assert output_path.read_bytes() == b"synthetic-signature" + + assert len(calls) == 7 + assert all(kwargs["creationflags"] == (windows_flag or 0) for _, kwargs in calls) + assert all(kwargs["capture_output"] is True for _, kwargs in calls)