Skip to content
Merged
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
6 changes: 5 additions & 1 deletion skills/shadow-frog-dream/dream-setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,12 @@ if git check-ignore -q .shadow/_dreams/__shadowfrog_probe__/manifest.json 2>/dev
fi

# --- Detect default branch ---
# `git symbolic-ref` exits non-zero when origin/HEAD is unset (git < 2.48 does
# not auto-create it on fetch). Guard with `|| true` so `set -euo pipefail`
# does not abort here — an empty result is expected and handled by the
# origin/main / origin/master fallback below.
DEFAULT_BRANCH=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null \
| sed 's|refs/remotes/origin/||')
| sed 's|refs/remotes/origin/||') || true
if [[ -z "$DEFAULT_BRANCH" ]]; then
if git show-ref --verify refs/remotes/origin/main >/dev/null 2>&1; then
DEFAULT_BRANCH="main"
Expand Down
128 changes: 128 additions & 0 deletions tests/_shell.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
"""Cross-platform helpers for the POSIX-shell integration tests.

The shell scripts under ``hook-templates/`` and ``skills/`` are POSIX ``bash``
scripts that internally call ``python3``, ``git`` and coreutils (``sed``,
``grep``, ``tr`` ...). Running them from the test suite on Windows has two
pitfalls that this module papers over:

1. **``bash`` resolution.** On a GitHub ``windows-latest`` runner a bare
``bash`` on ``PATH`` resolves to ``C:\\Windows\\System32\\bash.exe`` — the
WSL launcher stub, which has no distro installed and fails immediately.
:data:`BASH` instead points at the *Git Bash* interpreter that ships with
Git for Windows (preinstalled on every ``windows-latest`` runner).

2. **Tool discovery inside Git Bash.** Git Bash does not ship ``python3`` and
keeps ``git`` under ``mingw64/bin`` (not ``/usr/bin``). :func:`shell_path`
returns a ``PATH`` that makes ``python3`` (via a shim that execs the test
interpreter) and ``git`` discoverable, so the scripts behave exactly as on
Linux.

On non-Windows platforms both helpers are thin pass-throughs, so the Linux CI
behaviour is unchanged.
"""
import functools
import os
import shutil
import sys
import tempfile
from pathlib import Path

_DEFAULT_POSIX_PATH = "/usr/bin:/bin:/usr/local/bin"


def _find_bash() -> str:
"""Locate a real POSIX ``bash``.

Returns an empty string when none is available (e.g. a Windows dev box
without Git for Windows) so callers can skip gracefully instead of failing.
"""
if os.name != "nt":
return "bash"

override = os.environ.get("SHADOWFROG_BASH")
if override and Path(override).is_file():
return override

candidates = [
r"C:\Program Files\Git\bin\bash.exe",
r"C:\Program Files\Git\usr\bin\bash.exe",
r"C:\Program Files (x86)\Git\bin\bash.exe",
]
git = shutil.which("git")
if git:
# git.exe usually lives at <Git>\cmd\git.exe or <Git>\bin\git.exe.
candidates.append(str(Path(git).parent.parent / "bin" / "bash.exe"))

for candidate in candidates:
# Never accept System32\bash.exe — that is the WSL launcher stub.
if "System32" in candidate or "system32" in candidate:
continue
if Path(candidate).is_file():
return candidate
return ""


#: Path to a real POSIX ``bash`` ("bash" on POSIX, Git Bash on Windows, or ""
#: when unavailable).
BASH = _find_bash()

#: True when a usable POSIX shell was found. Use as a skip guard.
HAVE_BASH = bool(BASH)


@functools.lru_cache(maxsize=1)
def _python3_shim_dir() -> str:
"""Create a directory containing a ``python3`` launcher for Git Bash.

Git Bash has no ``python3``; the scripts hard-code that name. The shim
execs the *current* test interpreter, so the scripts run under exactly the
Python the suite uses.
"""
shim_dir = Path(tempfile.mkdtemp(prefix="sf-py3-shim-"))
shim = shim_dir / "python3"
shim.write_text(
'#!/bin/sh\nexec "%s" "$@"\n' % Path(sys.executable).as_posix(),
encoding="ascii",
)
os.chmod(shim, 0o755)
return str(shim_dir)


@functools.lru_cache(maxsize=1)
def _git_tool_dirs() -> tuple:
"""Git Bash bin directories that hold ``git`` and the coreutils."""
if not BASH:
return ()
git_root = Path(BASH).parent.parent # ...\Git\bin\bash.exe -> ...\Git
dirs = []
for sub in ("mingw64/bin", "usr/bin", "bin"):
candidate = git_root / sub
if candidate.is_dir():
dirs.append(str(candidate))
return tuple(dirs)


def shell_path(base: str | None = None) -> str:
"""Return a ``PATH`` for running the POSIX shell scripts via :data:`BASH`.

On POSIX this preserves the historical behaviour (inherit the ambient
``PATH``, or ``base`` when given). On Windows it returns a controlled
``PATH`` giving Git Bash access to ``python3`` (shim) and ``git`` +
coreutils, expressed as a Windows ``;``-separated string that Git Bash
converts to POSIX form at launch.
"""
if os.name != "nt":
if base is not None:
return base
return os.environ.get("PATH", _DEFAULT_POSIX_PATH)
return os.pathsep.join([_python3_shim_dir(), *_git_tool_dirs()])


def prepend_path(extra_dir) -> str:
"""Return :func:`shell_path` with ``extra_dir`` prepended.

Use instead of ``f"{stub}:{os.environ['PATH']}"`` so the correct path
separator (``:`` on POSIX, ``;`` on Windows) is used and the Windows tool
directories are still present.
"""
return os.pathsep.join([str(extra_dir), shell_path()])
18 changes: 18 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
bypasses the argument parser.
"""
import importlib.util
import os
import shutil
import subprocess
import sys
Expand Down Expand Up @@ -79,6 +80,23 @@ def meditate_repair(repo_root):

# --- Filesystem fixtures ---

@pytest.fixture
def make_symlink():
"""Create a symlink, skipping only when Windows denies the privilege."""
def create(link, target):
try:
link.symlink_to(target)
except OSError as exc:
if os.name == "nt" and exc.winerror == 1314:
pytest.skip(
"Windows symlink privilege is unavailable; enable "
"Developer Mode or run elevated"
)
raise

return create


@pytest.fixture(scope="session")
def coupon_demo_src(repo_root):
"""Read-only path to the canonical coupon-demo. Tests MUST NOT mutate this."""
Expand Down
17 changes: 14 additions & 3 deletions tests/hooks/test_check_init_sh.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,24 @@

import pytest

from tests._shell import BASH, HAVE_BASH, prepend_path, shell_path

# POSIX-shell integration tests: these shell out to a POSIX `bash`. On Windows
# that is Git Bash (resolved via BASH — never the System32 WSL launcher stub).
# Skip only when no POSIX shell is available at all (e.g. a Windows box without
# Git for Windows installed).
pytestmark = pytest.mark.skipif(
not HAVE_BASH,
reason="no POSIX bash (Git Bash) available for shell integration tests",
)

REPO_ROOT = Path(__file__).resolve().parent.parent.parent
HOOK_SCRIPT = REPO_ROOT / "hook-templates" / "scripts" / "shadow-frog-check-init.sh"


def _base_env(cwd: Path, extras: dict | None = None) -> dict:
env = {
"PATH": os.environ.get("PATH", "/usr/bin:/bin:/usr/local/bin"),
"PATH": shell_path(),
"HOME": str(cwd),
"GIT_CONFIG_GLOBAL": "/dev/null",
"GIT_CONFIG_SYSTEM": "/dev/null",
Expand All @@ -31,7 +42,7 @@ def run_hook(cwd: Path, env_extra: dict | None = None) -> subprocess.CompletedPr
"""Run the check-init hook (stdin is ignored but must exist)."""
env = _base_env(cwd, env_extra)
return subprocess.run(
["bash", str(HOOK_SCRIPT)],
[BASH, str(HOOK_SCRIPT)],
input="{}",
capture_output=True,
text=True,
Expand Down Expand Up @@ -261,7 +272,7 @@ def test_git_diff_failure_exits_zero(self, coupon_demo, tmp_path):
_make_failing_git_stub(stub, "diff")
result = run_hook(
cwd=coupon_demo,
env_extra={"PATH": f"{stub}:{os.environ.get('PATH', '')}"},
env_extra={"PATH": prepend_path(stub)},
)
assert result.returncode == 0, f"stderr={result.stderr}"
json.loads(result.stdout)
17 changes: 14 additions & 3 deletions tests/hooks/test_hook_fault_injection.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,16 @@

import pytest

from tests._shell import BASH, HAVE_BASH, prepend_path, shell_path

# POSIX-shell integration tests: these shell out to a POSIX `bash`. On Windows
# that is Git Bash (resolved via BASH — never the System32 WSL launcher stub).
# Skip only when no POSIX shell is available at all.
pytestmark = pytest.mark.skipif(
not HAVE_BASH,
reason="no POSIX bash (Git Bash) available for shell integration tests",
)

REPO_ROOT = Path(__file__).resolve().parent.parent.parent
PRE_TOOL_HOOK = REPO_ROOT / "hook-templates" / "scripts" / "shadow-frog-pre-tool.sh"
CHECK_INIT_HOOK = REPO_ROOT / "hook-templates" / "scripts" / "shadow-frog-check-init.sh"
Expand Down Expand Up @@ -69,7 +79,7 @@

def _base_env(cwd: Path, extras: dict | None = None) -> dict:
env = {
"PATH": os.environ.get("PATH", "/usr/bin:/bin:/usr/local/bin"),
"PATH": shell_path(),
"HOME": str(cwd),
"GIT_CONFIG_GLOBAL": "/dev/null",
"GIT_CONFIG_SYSTEM": "/dev/null",
Expand Down Expand Up @@ -108,8 +118,9 @@ def _run(hook: Path, cwd: Path, stdin: str, env_extra: dict | None = None,
extras["SHADOWFROG_TMP_DIR"] = str(cwd / "_sf_dedup")
t0 = time.perf_counter()
cp = subprocess.run(
["bash", str(hook)],
[BASH, str(hook)],
input=stdin, capture_output=True, text=True,
encoding="utf-8", errors="replace",
cwd=cwd, env=_base_env(cwd, extras), timeout=timeout,
)
return cp, time.perf_counter() - t0
Expand Down Expand Up @@ -302,7 +313,7 @@ def test_binary_fault_injection(tmp_path, hook, scenario_id, stub_factory):

stub_dir = tmp_path / "stubbin"
stub_factory(stub_dir)
env = {"PATH": f"{stub_dir}:{os.environ.get('PATH', '')}"}
env = {"PATH": prepend_path(stub_dir)}

payload = json.dumps({"toolName": "edit",
"toolInput": {"file_path": "a.py"}})
Expand Down
30 changes: 23 additions & 7 deletions tests/hooks/test_pre_tool_sh.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@

import pytest

from tests._shell import BASH, HAVE_BASH, prepend_path, shell_path

# POSIX-shell integration tests: these shell out to a POSIX `bash`. On Windows
# that is Git Bash (resolved via BASH — never the System32 WSL launcher stub).
# Skip only when no POSIX shell is available at all.
pytestmark = pytest.mark.skipif(
not HAVE_BASH,
reason="no POSIX bash (Git Bash) available for shell integration tests",
)

REPO_ROOT = Path(__file__).resolve().parent.parent.parent
HOOK_SCRIPT = REPO_ROOT / "hook-templates" / "scripts" / "shadow-frog-pre-tool.sh"

Expand Down Expand Up @@ -45,7 +55,7 @@ def _make_failing_git_stub(stub_dir: Path, fail_subcommand: str = "diff") -> Pat
def _base_env(cwd: Path, extras: dict | None = None) -> dict:
"""Minimal env isolating from user environment but preserving PATH for python3/git."""
env = {
"PATH": os.environ.get("PATH", "/usr/bin:/bin:/usr/local/bin"),
"PATH": shell_path(),
"HOME": str(cwd),
"GIT_CONFIG_GLOBAL": "/dev/null",
"GIT_CONFIG_SYSTEM": "/dev/null",
Expand All @@ -60,7 +70,7 @@ def run_hook(json_input: dict, cwd: Path, env_extra: dict | None = None) -> subp
"""Run the pre-tool hook with given JSON on stdin."""
env = _base_env(cwd, env_extra)
return subprocess.run(
["bash", str(HOOK_SCRIPT)],
[BASH, str(HOOK_SCRIPT)],
input=json.dumps(json_input),
capture_output=True,
text=True,
Expand Down Expand Up @@ -336,7 +346,7 @@ def test_git_diff_failure_does_not_deny(self, coupon_demo, tmp_path):
self._set_stale_state(coupon_demo)
stub = tmp_path / "stubbin"
_make_failing_git_stub(stub, "diff")
env = {"PATH": f"{stub}:{os.environ.get('PATH', '')}"}
env = {"PATH": prepend_path(stub)}
result = run_hook(
{"tool_name": "Bash", "tool_input": {"command": "ls"}},
cwd=coupon_demo, env_extra=env,
Expand Down Expand Up @@ -377,7 +387,7 @@ def test_git_rev_parse_failure_does_not_deny(self, coupon_demo, tmp_path):
self._set_stale_state(coupon_demo)
stub = tmp_path / "stubbin"
_make_failing_git_stub(stub, "rev-parse")
env = {"PATH": f"{stub}:{os.environ.get('PATH', '')}"}
env = {"PATH": prepend_path(stub)}
result = run_hook(
{"tool_name": "edit", "tool_input": {"file_path": "cart.py"}},
cwd=coupon_demo, env_extra=env,
Expand Down Expand Up @@ -448,6 +458,12 @@ def test_mutation_tool_triggers_file_specific_branch(

@pytest.mark.slow
@pytest.mark.integration
@pytest.mark.skipif(
os.name == "nt",
reason="POSIX signal semantics: Windows has no SIGTERM trap — "
"send_signal(SIGTERM) calls TerminateProcess (hard kill), so the "
"bash `trap 'exit 0' TERM` pyramid cannot run. Validated on Linux.",
)
class TestPreToolSigterm:
"""Behavioral verification of `trap 'exit 0' TERM` — distinct from
static CI checker coverage."""
Expand All @@ -463,7 +479,7 @@ def _spawn_and_signal(self, coupon_demo, tmp_path, env_extra=None,
)
t0 = time.perf_counter()
proc = subprocess.Popen(
["bash", str(HOOK_SCRIPT)],
[BASH, str(HOOK_SCRIPT)],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
Expand Down Expand Up @@ -550,7 +566,7 @@ def test_sigterm_during_hung_subprocess_still_eventually_exits(
rc, stdout, stderr, elapsed = self._spawn_and_signal(
coupon_demo, tmp_path,
env_extra={
"PATH": f"{stub}:{os.environ.get('PATH','')}",
"PATH": prepend_path(stub),
"SHADOWFROG_TMP_DIR": str(tmp_path / "dedup"),
},
signal_delay=0.05,
Expand Down Expand Up @@ -597,7 +613,7 @@ def test_happy_path_meets_strict_production_budget(
for attempt in range(3):
t0 = time.perf_counter()
result = subprocess.run(
["bash", str(HOOK_SCRIPT)],
[BASH, str(HOOK_SCRIPT)],
input=payload,
capture_output=True,
text=True,
Expand Down
Loading