test(e2e): cross-OS wiring harness + 3-OS CI, red-first (FPLAN-0239)
Build-wheel -> install-clean -> assert 4-tier wiring ladder (install/init/ hooks-fire/drone-route). Validated green on Linux; Windows red-first by design (symlink/venv-path/tmp gaps = DPLAN-0194 P3 fix-list). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
3ad0580070
commit
cd1af34be8
@@ -0,0 +1,50 @@
|
||||
name: e2e-wheel
|
||||
|
||||
# Cross-OS end-to-end WIRING test (FPLAN-0239, P1 of DPLAN-0194).
|
||||
# Builds the wheel, installs it into a clean venv (handled by the pytest
|
||||
# fixtures in tests/e2e/conftest.py), and runs the 4-tier wiring ladder.
|
||||
#
|
||||
# RED-FIRST: Windows is EXPECTED to fail in known places (symlink init,
|
||||
# bin-vs-Scripts, /tmp). Do not "fix" Windows here — the red is the deliverable.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- "tests/e2e/**"
|
||||
- ".github/workflows/e2e-wheel.yml"
|
||||
- "pyproject.toml"
|
||||
- "src/**"
|
||||
pull_request:
|
||||
paths:
|
||||
- "tests/e2e/**"
|
||||
- ".github/workflows/e2e-wheel.yml"
|
||||
- "pyproject.toml"
|
||||
- "src/**"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
e2e-wheel:
|
||||
name: e2e-wheel (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
python-version: ["3.12"]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install build tooling
|
||||
run: python -m pip install --upgrade pip build pytest
|
||||
|
||||
- name: Run cross-OS e2e wiring harness
|
||||
# conftest.py builds the wheel + clean venv internally; the outer env
|
||||
# only needs build + pytest.
|
||||
run: python -m pytest tests/e2e -v
|
||||
@@ -12,6 +12,16 @@ and this project uses [Calendar Versioning](https://calver.org/) in the format
|
||||
|
||||
### Added
|
||||
|
||||
- **Cross-OS end-to-end WIRING test (`tests/e2e/`, `e2e-wheel.yml`)** — the first
|
||||
CI gate that proves real AIPass *wiring* (not units-with-mocks) by building the
|
||||
wheel, installing it into a clean venv, and asserting a 4-tier ladder: package
|
||||
install + console scripts (T0), `aipass init` scaffolding (T1), a hook actually
|
||||
firing via the bridge with an observable `engine.jsonl` record (T2a), and
|
||||
`drone` resolving + subprocess-executing a real branch (T3). Runs on a 3-OS
|
||||
matrix (ubuntu/windows/macos, `fail-fast: false`). Validated green on Linux;
|
||||
Windows is **red-first by design** — the expected failures (unguarded `.venv`
|
||||
symlink, `.venv/bin` vs `Scripts`, hardcoded `/tmp`) are the portability
|
||||
fix-list, not regressions. (DPLAN-0194 / FPLAN-0239)
|
||||
- **`drone rm` — provider-agnostic safe delete** — a contained recursive delete
|
||||
that lets agents clean up scratch dirs without tripping the `rm -rf` block.
|
||||
Deletes are confined to the project root and the system temp dirs (`/tmp` and
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# DPLAN-0194 — P1 cross-OS e2e wiring harness PROTOTYPE (Linux/Docker dev loop).
|
||||
# Runs INSIDE a clean container with the repo bind-mounted read-only at /repo.
|
||||
# Builds a wheel, installs into a CLEAN venv, then asserts the 4 tiers:
|
||||
# T0 install+binaries T1 aipass init scaffold T2a synthetic hook fire T3 drone routing
|
||||
# Tolerant: never exits on first failure — runs every assertion so we see the full red/green ladder.
|
||||
#
|
||||
set -uo pipefail
|
||||
|
||||
P=0; F=0
|
||||
ok(){ echo " ok $1"; P=$((P+1)); }
|
||||
no(){ echo " XX $1"; F=$((F+1)); }
|
||||
chk(){ if eval "$2" >/dev/null 2>&1; then ok "$1"; else no "$1"; fi; }
|
||||
hdr(){ echo; echo "=== $1 ==="; }
|
||||
|
||||
SRC=~/src
|
||||
BUILDENV=/tmp/buildenv
|
||||
CLEANENV=/tmp/cleanenv
|
||||
DIST=/tmp/dist
|
||||
PY=$CLEANENV/bin/python
|
||||
AIPASS=$CLEANENV/bin/aipass
|
||||
DRONE=$CLEANENV/bin/drone
|
||||
|
||||
hdr "SETUP — copy repo (writable), build wheel"
|
||||
rm -rf "$SRC" "$DIST" "$BUILDENV" "$CLEANENV"
|
||||
cp -r /repo "$SRC" 2>/dev/null || true # .trinity memory files are perm-restricted; harmless, code copies fine
|
||||
cd "$SRC"
|
||||
# A real fresh-clone runs setup.sh to GENERATE the registry (the host's AIPASS_REGISTRY.json
|
||||
# is mode 0600 and won't copy across uids anyway). Synthesize a minimal one pointing at the
|
||||
# copied branches — faithful to what setup.sh produces, lets Tier 3 prove routing plumbing.
|
||||
cat > "$SRC/AIPASS_REGISTRY.json" <<JSON
|
||||
{ "metadata": { "name": "AIPASS", "version": "1.0.0", "total_branches": 2 },
|
||||
"branches": [
|
||||
{ "name": "drone", "path": "$SRC/src/aipass/drone" },
|
||||
{ "name": "seedgo", "path": "$SRC/src/aipass/seedgo" }
|
||||
] }
|
||||
JSON
|
||||
python3 -m venv "$BUILDENV"
|
||||
"$BUILDENV/bin/pip" -q install --upgrade pip build 2>&1 | tail -2
|
||||
echo " building wheel..."
|
||||
"$BUILDENV/bin/python" -m build --wheel --outdir "$DIST" . 2>&1 | tail -4
|
||||
WHEEL=$(ls "$DIST"/*.whl 2>/dev/null | head -1)
|
||||
echo " wheel: ${WHEEL:-<NONE>}"
|
||||
|
||||
hdr "TIER 0 — clean-venv wheel install + binaries"
|
||||
python3 -m venv "$CLEANENV"
|
||||
if [ -n "${WHEEL:-}" ]; then
|
||||
"$CLEANENV/bin/pip" -q install "$WHEEL" 2>&1 | tail -3
|
||||
fi
|
||||
chk "wheel built" "[ -n '${WHEEL:-}' ]"
|
||||
chk "clean venv has pip (not silent-broken venv, #495)" "[ -x '$CLEANENV/bin/pip' ]"
|
||||
chk "aipass console_script installed" "[ -x '$AIPASS' ]"
|
||||
chk "drone console_script installed" "[ -x '$DRONE' ]"
|
||||
chk "drone --version runs" "'$DRONE' --version"
|
||||
chk "aipass entrypoint imports (aipass init --help)" "'$AIPASS' init --help"
|
||||
|
||||
hdr "TIER 1 — aipass init scaffolds correctly"
|
||||
PROJ=/tmp/proj; rm -rf "$PROJ"
|
||||
# AIPASS_HOME left UNSET on purpose: tests core scaffold independent of venv/templates,
|
||||
# and sidesteps the .venv symlink (the symlink bug is a Windows-only failure — N/A on Linux).
|
||||
"$AIPASS" init "$PROJ" demo > /tmp/init.out 2>&1
|
||||
echo " init exit=$? (see /tmp/init.out)"; tail -3 /tmp/init.out | sed 's/^/ | /'
|
||||
chk "DEMO_REGISTRY.json exists" "[ -f '$PROJ/DEMO_REGISTRY.json' ]"
|
||||
chk "DEMO_REGISTRY.json is valid JSON" "jq -e . '$PROJ/DEMO_REGISTRY.json'"
|
||||
chk "registry metadata.name == DEMO" "[ \"\$(jq -r .metadata.name '$PROJ/DEMO_REGISTRY.json')\" = DEMO ]"
|
||||
chk ".claude/settings.json exists" "[ -f '$PROJ/.claude/settings.json' ]"
|
||||
chk "settings deny has EnterPlanMode" "jq -e '.permissions.deny|index(\"EnterPlanMode\")' '$PROJ/.claude/settings.json'"
|
||||
chk "src/demo/__init__.py exists" "[ -f '$PROJ/src/demo/__init__.py' ]"
|
||||
chk ".gitignore mentions .venv" "grep -q '.venv' '$PROJ/.gitignore'"
|
||||
chk ".trinity/ NOT created (projects!=citizens)" "[ ! -e '$PROJ/.trinity' ]"
|
||||
chk "no passport.json created" "[ ! -e '$PROJ/.trinity/passport.json' ]"
|
||||
|
||||
hdr "TIER 2a — synthetic hook fire (module form, sentinel UUID, engine.jsonl)"
|
||||
HOOKP=/tmp/hookproj; rm -rf "$HOOKP"; mkdir -p "$HOOKP/.aipass"
|
||||
# minimal isolated config: ONLY rm_gate enabled -> no git_gate/sound noise
|
||||
cat > "$HOOKP/.aipass/hooks.json" <<'JSON'
|
||||
{ "hooks_enabled": true,
|
||||
"PreToolUse": {
|
||||
"rm_gate": { "enabled": true, "handler": "aipass.hooks.apps.handlers.security.rm_gate.handle", "matcher": "Bash" }
|
||||
} }
|
||||
JSON
|
||||
UUID="PROTOUUID12345"
|
||||
LOG=$(find "$CLEANENV" -path '*/aipass/hooks/logs/engine.jsonl' 2>/dev/null | head -1)
|
||||
LOGDIR=$(dirname "$(find "$CLEANENV" -path '*/aipass/hooks' -type d 2>/dev/null | head -1)")
|
||||
[ -n "$LOG" ] && : > "$LOG" # truncate if present
|
||||
# fire: rm -rf -> expect block (exit 2)
|
||||
OUT=$(cd "$HOOKP" && echo "{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"rm -rf /tmp/x\"},\"agent_id\":\"$UUID\"}" \
|
||||
| AIPASS_HOME="$HOOKP" "$PY" -m aipass.hooks.apps.handlers.bridges.claude "PreToolUse:rm_gate" 2>/tmp/hook.err)
|
||||
HX=$?
|
||||
# relocate LOG now if it didn't exist before
|
||||
[ -z "$LOG" ] && LOG=$(find "$CLEANENV" -path '*/aipass/hooks/logs/engine.jsonl' 2>/dev/null | head -1)
|
||||
printf '%s' "$OUT" > /tmp/hook.out # write to file: never eval-interpolate captured JSON
|
||||
echo " hook exit=$HX stdout=${OUT:0:80}"
|
||||
[ -s /tmp/hook.err ] && echo " stderr: $(head -1 /tmp/hook.err)"
|
||||
# REAL contract (discovered by prototype): rm_gate blocks via {"decision":"block"} on STDOUT, exit 0 — NOT exit 2.
|
||||
chk "rm_gate decision==block (stdout JSON)" "jq -e '.decision==\"block\"' /tmp/hook.out"
|
||||
chk "bridge exit 0 (block via JSON not code)" "[ '$HX' = 0 ]"
|
||||
chk "engine.jsonl exists" "[ -n '$LOG' ] && [ -f '$LOG' ]"
|
||||
chk "engine.jsonl logged sentinel UUID" "[ -n '$LOG' ] && grep -q '$UUID' '$LOG'"
|
||||
chk "logged record hook==rm_gate" "[ -n '$LOG' ] && grep '$UUID' '$LOG' | grep -q rm_gate"
|
||||
# negative: harmless echo -> allow (exit 0)
|
||||
OUT2=$(cd "$HOOKP" && echo "{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"echo hi\"},\"agent_id\":\"$UUID-neg\"}" \
|
||||
| AIPASS_HOME="$HOOKP" "$PY" -m aipass.hooks.apps.handlers.bridges.claude "PreToolUse:rm_gate" 2>/dev/null)
|
||||
HX2=$?
|
||||
chk "rm_gate allows echo (exit 0)" "[ '$HX2' = 0 ]"
|
||||
|
||||
hdr "TIER 3 — drone routing (against real repo registry)"
|
||||
chk "drone systems runs (reads registry)" "cd '$SRC' && '$DRONE' systems"
|
||||
chk "drone systems lists a known branch" "cd '$SRC' && '$DRONE' systems 2>/dev/null | grep -qi seedgo"
|
||||
chk "drone @drone --help routes" "cd '$SRC' && '$DRONE' @drone --help"
|
||||
|
||||
hdr "RESULT"
|
||||
echo " PASS=$P FAIL=$F"
|
||||
[ "$F" -eq 0 ] && echo " ALL GREEN" || echo " $F red — that's the truth we wanted"
|
||||
exit 0
|
||||
@@ -0,0 +1,139 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: conftest.py
|
||||
# Description: Session-scoped fixtures for the cross-OS e2e wiring harness
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-06-03
|
||||
# =============================================
|
||||
|
||||
"""Session-scoped pytest fixtures for the cross-OS end-to-end WIRING harness.
|
||||
|
||||
These fixtures build the aipass wheel and install it into a FRESH, clean venv
|
||||
(never the repo .venv, never ``pip install -e``) so the tests in this package
|
||||
exercise the real installed package the way a contributor on any OS would.
|
||||
|
||||
CRITICAL cross-OS-harness rule: this harness must itself run on Windows. The
|
||||
venv binary directory is ``Scripts`` on Windows and ``bin`` on POSIX, and the
|
||||
script extension is ``.exe`` on Windows. We resolve those from ``os.name`` /
|
||||
``sys.executable`` and never hardcode — the harness must NOT contain the very
|
||||
bugs it tests for.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Repo root = three levels up from this file: <repo>/tests/e2e/conftest.py
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _venv_bin_dir(venv_root: Path) -> Path:
|
||||
"""Return the venv directory that holds executables for THIS platform.
|
||||
|
||||
Windows venvs put scripts in ``Scripts``; POSIX venvs use ``bin``. This is
|
||||
exactly the bin-vs-Scripts split the harness exists to expose, so the
|
||||
harness must resolve it correctly itself.
|
||||
"""
|
||||
return venv_root / ("Scripts" if os.name == "nt" else "bin")
|
||||
|
||||
|
||||
def _exe(name: str) -> str:
|
||||
"""Append the Windows executable suffix to a console-script name."""
|
||||
return f"{name}.exe" if os.name == "nt" else name
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CleanVenv:
|
||||
"""Paths into a clean venv with the aipass wheel installed."""
|
||||
|
||||
root: Path
|
||||
python: Path
|
||||
pip: Path
|
||||
aipass: Path
|
||||
drone: Path
|
||||
site_packages: Path
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def wheel(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
||||
"""Build the aipass wheel from the repo root and return its path.
|
||||
|
||||
Uses ``python -m build --wheel`` (build backend = hatchling, package =
|
||||
aipass 2.5.0). The outer environment running pytest only needs ``build``
|
||||
and ``pytest`` installed — this fixture produces the wheel that the
|
||||
clean-venv fixture then installs.
|
||||
"""
|
||||
dist_dir = tmp_path_factory.mktemp("wheel_dist")
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "build", "--wheel", "--outdir", str(dist_dir), str(REPO_ROOT)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(
|
||||
"wheel build failed (exit "
|
||||
f"{result.returncode}).\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}"
|
||||
)
|
||||
|
||||
wheels = sorted(dist_dir.glob("*.whl"))
|
||||
if not wheels:
|
||||
raise RuntimeError(f"no wheel produced in {dist_dir}. build output:\n{result.stdout}")
|
||||
return wheels[0]
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def clean_venv(wheel: Path, tmp_path_factory: pytest.TempPathFactory) -> CleanVenv:
|
||||
"""Create a fresh venv and install the wheel into it.
|
||||
|
||||
NOT the repo .venv, NOT an editable install — a brand-new venv with the
|
||||
built wheel installed, so we test the wiring of the real package.
|
||||
"""
|
||||
venv_root = tmp_path_factory.mktemp("clean_venv")
|
||||
|
||||
# Build the venv with the current interpreter — no hardcoded "python".
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "venv", str(venv_root)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"venv creation failed:\n{result.stdout}\n{result.stderr}")
|
||||
|
||||
bin_dir = _venv_bin_dir(venv_root)
|
||||
py = bin_dir / _exe("python")
|
||||
pip = bin_dir / _exe("pip")
|
||||
|
||||
install = subprocess.run(
|
||||
[str(py), "-m", "pip", "install", str(wheel)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if install.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"wheel install into clean venv failed:\nSTDOUT:\n{install.stdout}\nSTDERR:\n{install.stderr}"
|
||||
)
|
||||
|
||||
# Resolve site-packages from the venv's own interpreter — portable across
|
||||
# OSes and python minor versions instead of guessing lib/pythonX.Y.
|
||||
sp = subprocess.run(
|
||||
[str(py), "-c", "import sysconfig; print(sysconfig.get_path('purelib'))"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if sp.returncode != 0:
|
||||
raise RuntimeError(f"could not resolve site-packages:\n{sp.stdout}\n{sp.stderr}")
|
||||
site_packages = Path(sp.stdout.strip())
|
||||
|
||||
return CleanVenv(
|
||||
root=venv_root,
|
||||
python=py,
|
||||
pip=pip,
|
||||
aipass=bin_dir / _exe("aipass"),
|
||||
drone=bin_dir / _exe("drone"),
|
||||
site_packages=site_packages,
|
||||
)
|
||||
@@ -0,0 +1,378 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_wiring.py
|
||||
# Description: Cross-OS end-to-end WIRING tests against the installed wheel
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-06-03
|
||||
# =============================================
|
||||
|
||||
"""Cross-OS end-to-end WIRING tests (FPLAN-0239, P1 of DPLAN-0194).
|
||||
|
||||
This proves AIPass *wiring* — not units-with-mocks — by building the wheel,
|
||||
installing it into a clean venv (see ``conftest.py``), and asserting a 4-tier
|
||||
ladder against the real installed package:
|
||||
|
||||
T0 install + console scripts exist and run
|
||||
T1 ``aipass init`` scaffolds a project correctly
|
||||
T2a a hook actually fires, blocks, and leaves a sentinel record
|
||||
T3 ``drone`` resolves a real branch and executes it via subprocess
|
||||
|
||||
It is RED-FIRST: it is expected to fail on Windows in known places (symlink
|
||||
init, bin-vs-Scripts, /tmp). The harness itself is written to be cross-OS so
|
||||
those reds reflect AIPass bugs, not harness bugs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import REPO_ROOT, CleanVenv
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _run(cmd: list[str], **kwargs) -> subprocess.CompletedProcess:
|
||||
"""Run a subprocess capturing text output with a bounded timeout."""
|
||||
kwargs.setdefault("capture_output", True)
|
||||
kwargs.setdefault("text", True)
|
||||
kwargs.setdefault("timeout", 60)
|
||||
return subprocess.run(cmd, **kwargs)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TIER 0 — clean-venv wheel install + binaries
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def test_t0_wheel_built(wheel: Path) -> None:
|
||||
"""The wheel built and is named for aipass."""
|
||||
assert wheel.exists()
|
||||
assert wheel.suffix == ".whl"
|
||||
assert wheel.name.startswith("aipass-")
|
||||
|
||||
|
||||
def test_t0_venv_has_pip(clean_venv: CleanVenv) -> None:
|
||||
"""Clean venv has a working pip — not a silently-broken venv (ref #495)."""
|
||||
result = _run([str(clean_venv.python), "-m", "pip", "--version"])
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
def test_t0_console_scripts_exist(clean_venv: CleanVenv) -> None:
|
||||
"""Both ``aipass`` and ``drone`` console scripts are installed."""
|
||||
assert clean_venv.aipass.exists(), f"missing aipass at {clean_venv.aipass}"
|
||||
assert clean_venv.drone.exists(), f"missing drone at {clean_venv.drone}"
|
||||
|
||||
|
||||
def test_t0_drone_version_runs(clean_venv: CleanVenv) -> None:
|
||||
"""``drone --version`` runs (entry point imports cleanly)."""
|
||||
result = _run([str(clean_venv.drone), "--version"])
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
def test_t0_aipass_init_help_runs(clean_venv: CleanVenv) -> None:
|
||||
"""``aipass init --help`` runs (the init entry point imports cleanly)."""
|
||||
result = _run([str(clean_venv.aipass), "init", "--help"])
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TIER 1 — aipass init scaffolds correctly
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def init_project(clean_venv: CleanVenv, tmp_path_factory: pytest.TempPathFactory) -> Path:
|
||||
"""Run ``aipass init <proj> demo`` in a clean neutral dir, return the project.
|
||||
|
||||
``aipass init`` REFUSES when a ``*_REGISTRY.json`` sits in or above CWD
|
||||
(``_guard_init``), so we run it from a pristine tmp dir whose parents have
|
||||
no registry. ``AIPASS_HOME`` is left UNSET so the .venv-symlink step (a
|
||||
Windows-only failure tracked separately) is skipped — this test must pass
|
||||
on Linux.
|
||||
"""
|
||||
neutral = tmp_path_factory.mktemp("neutral_init_cwd")
|
||||
proj = neutral / "proj"
|
||||
|
||||
env = {
|
||||
"PATH": _path_env(),
|
||||
"HOME": str(neutral),
|
||||
}
|
||||
if sys.platform == "win32":
|
||||
# Windows needs a few base vars for subprocess/venv tooling to work.
|
||||
import os as _os
|
||||
|
||||
for key in ("SYSTEMROOT", "TEMP", "TMP", "USERPROFILE", "PATHEXT", "COMSPEC"):
|
||||
if key in _os.environ:
|
||||
env[key] = _os.environ[key]
|
||||
|
||||
result = subprocess.run(
|
||||
[str(clean_venv.aipass), "init", str(proj), "demo"],
|
||||
cwd=str(neutral),
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
assert result.returncode == 0, (
|
||||
f"aipass init failed (exit {result.returncode}).\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}"
|
||||
)
|
||||
return proj
|
||||
|
||||
|
||||
def _path_env() -> str:
|
||||
"""Minimal PATH for the init subprocess (portable across OSes)."""
|
||||
import os
|
||||
|
||||
return os.environ.get("PATH", "")
|
||||
|
||||
|
||||
def test_t1_registry_created_and_valid(init_project: Path) -> None:
|
||||
"""DEMO_REGISTRY.json exists, is valid JSON, and names DEMO."""
|
||||
registry = init_project / "DEMO_REGISTRY.json"
|
||||
assert registry.is_file(), f"missing {registry}"
|
||||
data = json.loads(registry.read_text(encoding="utf-8"))
|
||||
assert data["metadata"]["name"] == "DEMO"
|
||||
|
||||
|
||||
def test_t1_claude_settings_deny_enterplanmode(init_project: Path) -> None:
|
||||
""".claude/settings.json exists and its permissions.deny blocks EnterPlanMode."""
|
||||
settings = init_project / ".claude" / "settings.json"
|
||||
assert settings.is_file(), f"missing {settings}"
|
||||
data = json.loads(settings.read_text(encoding="utf-8"))
|
||||
assert "EnterPlanMode" in data["permissions"]["deny"]
|
||||
|
||||
|
||||
def test_t1_src_package_scaffolded(init_project: Path) -> None:
|
||||
"""src/demo/__init__.py is created."""
|
||||
assert (init_project / "src" / "demo" / "__init__.py").is_file()
|
||||
|
||||
|
||||
def test_t1_gitignore_mentions_venv(init_project: Path) -> None:
|
||||
""".gitignore mentions .venv."""
|
||||
gitignore = init_project / ".gitignore"
|
||||
assert gitignore.is_file()
|
||||
assert ".venv" in gitignore.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_t1_no_trinity_no_passport(init_project: Path) -> None:
|
||||
"""Projects are NOT citizens: no .trinity/ dir and no passport.json."""
|
||||
assert not (init_project / ".trinity").exists()
|
||||
assert not (init_project / ".trinity" / "passport.json").exists()
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TIER 2a — synthetic hook fire (module form, sentinel UUID, engine.jsonl)
|
||||
# ===========================================================================
|
||||
|
||||
_RM_GATE_CONFIG = {
|
||||
"hooks_enabled": True,
|
||||
"PreToolUse": {
|
||||
"rm_gate": {
|
||||
"enabled": True,
|
||||
"handler": "aipass.hooks.apps.handlers.security.rm_gate.handle",
|
||||
"matcher": "Bash",
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _fire_hook(clean_venv: CleanVenv, work: Path, command: str, agent_id: str) -> subprocess.CompletedProcess:
|
||||
"""Invoke the Claude bridge in MODULE form for a single PreToolUse event.
|
||||
|
||||
Module form (``python -m aipass.hooks...``) is venv-portable and avoids the
|
||||
bin/Scripts split — the very bug this suite tests for.
|
||||
"""
|
||||
payload = json.dumps(
|
||||
{
|
||||
"tool_name": "Bash",
|
||||
"tool_input": {"command": command},
|
||||
"agent_id": agent_id,
|
||||
}
|
||||
)
|
||||
env = _hook_env(work)
|
||||
return subprocess.run(
|
||||
[
|
||||
str(clean_venv.python),
|
||||
"-m",
|
||||
"aipass.hooks.apps.handlers.bridges.claude",
|
||||
"PreToolUse:rm_gate",
|
||||
],
|
||||
input=payload,
|
||||
cwd=str(work),
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
|
||||
def _hook_env(work: Path) -> dict:
|
||||
"""Build a minimal env for the hook subprocess, with AIPASS_HOME isolated."""
|
||||
import os
|
||||
|
||||
env = dict(os.environ)
|
||||
env["AIPASS_HOME"] = str(work)
|
||||
return env
|
||||
|
||||
|
||||
def _engine_log(clean_venv: CleanVenv) -> Path | None:
|
||||
"""Glob the installed package for aipass/hooks/logs/engine.jsonl."""
|
||||
hits = sorted(clean_venv.site_packages.glob("aipass/hooks/logs/engine.jsonl"))
|
||||
return hits[0] if hits else None
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def hook_workspace(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
||||
"""A tmp workspace holding an isolated .aipass/hooks.json with only rm_gate."""
|
||||
work = tmp_path_factory.mktemp("hook_ws")
|
||||
aipass_dir = work / ".aipass"
|
||||
aipass_dir.mkdir(parents=True, exist_ok=True)
|
||||
(aipass_dir / "hooks.json").write_text(json.dumps(_RM_GATE_CONFIG), encoding="utf-8")
|
||||
return work
|
||||
|
||||
|
||||
def test_t2a_rm_gate_blocks(clean_venv: CleanVenv, hook_workspace: Path) -> None:
|
||||
"""rm -rf is blocked via {"decision":"block"} on STDOUT with exit code 0.
|
||||
|
||||
Corrected contract (NOT exit 2): the bridge writes the engine's block JSON
|
||||
to stdout and exits 0.
|
||||
"""
|
||||
sentinel = f"e2e-block-{uuid.uuid4()}"
|
||||
proc = _fire_hook(clean_venv, hook_workspace, "rm -rf /tmp/x", sentinel)
|
||||
|
||||
assert proc.returncode == 0, f"expected exit 0, got {proc.returncode}. stderr:\n{proc.stderr}"
|
||||
decision = json.loads(proc.stdout)
|
||||
assert decision.get("decision") == "block", f"stdout was: {proc.stdout!r}"
|
||||
|
||||
# Oracle: the engine log must hold a record with OUR sentinel AND hook==rm_gate.
|
||||
log = _engine_log(clean_venv)
|
||||
assert log is not None and log.is_file(), "engine.jsonl not found in installed package"
|
||||
assert _log_has_sentinel_for_hook(log, sentinel, "rm_gate"), (
|
||||
f"no engine.jsonl record found for sentinel {sentinel} + hook rm_gate"
|
||||
)
|
||||
|
||||
|
||||
def _log_has_sentinel_for_hook(log: Path, sentinel: str, hook: str) -> bool:
|
||||
"""Return True if any JSONL record has this agent_id AND this hook name.
|
||||
|
||||
Asserts on the sentinel, never line counts — the log is shared with live
|
||||
sessions.
|
||||
"""
|
||||
for line in log.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
rec = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if rec.get("agent_id") == sentinel and rec.get("hook") == hook:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def test_t2a_rm_gate_allows_echo(clean_venv: CleanVenv, hook_workspace: Path) -> None:
|
||||
"""A harmless command is allowed: exit 0 and decision is not "block"."""
|
||||
sentinel = f"e2e-allow-{uuid.uuid4()}"
|
||||
proc = _fire_hook(clean_venv, hook_workspace, "echo hi", sentinel)
|
||||
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
if proc.stdout.strip():
|
||||
try:
|
||||
decision = json.loads(proc.stdout)
|
||||
assert decision.get("decision") != "block", f"unexpected block: {proc.stdout!r}"
|
||||
except json.JSONDecodeError:
|
||||
# Non-JSON / empty output is also a valid "allow" signal.
|
||||
pass
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TIER 3 — drone routing (real resolve -> subprocess -> execute)
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def routing_root(clean_venv: CleanVenv) -> Iterator[Path]:
|
||||
"""Generate a minimal registry at the repo root pointing at real branches.
|
||||
|
||||
The repo's own AIPASS_REGISTRY.json is mode 0600 / host-absolute and won't
|
||||
relocate (its paths are the developer's home, absent in CI), so we GENERATE
|
||||
a minimal registry — faithful to what setup.sh produces — pointing at REAL
|
||||
branch dirs under this checkout.
|
||||
|
||||
drone validates path containment against the registry's PARENT dir, so the
|
||||
registry must sit at the repo root for the ``src/aipass/*`` branch paths to
|
||||
validate. We back up any existing registry and restore it on teardown so a
|
||||
local run never mutates the working tree permanently.
|
||||
|
||||
We target ``ai_mail`` — a real BRANCH (not an in-process drone module) — so
|
||||
``drone @ai_mail --help`` exercises the full resolve -> subprocess ->
|
||||
execute path, the proof we never previously got green.
|
||||
"""
|
||||
src = REPO_ROOT / "src" / "aipass"
|
||||
registry = {
|
||||
"metadata": {"name": "AIPASS", "version": "1.0.0", "total_branches": 3},
|
||||
"branches": [
|
||||
{"name": "ai_mail", "path": str(src / "ai_mail"), "status": "active"},
|
||||
{"name": "seedgo", "path": str(src / "seedgo"), "status": "active"},
|
||||
{"name": "drone", "path": str(src / "drone"), "status": "active"},
|
||||
],
|
||||
}
|
||||
|
||||
registry_path = REPO_ROOT / "AIPASS_REGISTRY.json"
|
||||
backup = registry_path.read_bytes() if registry_path.exists() else None
|
||||
try:
|
||||
registry_path.write_text(json.dumps(registry, indent=2), encoding="utf-8")
|
||||
yield REPO_ROOT
|
||||
finally:
|
||||
if backup is not None:
|
||||
registry_path.write_bytes(backup)
|
||||
elif registry_path.exists():
|
||||
registry_path.unlink()
|
||||
|
||||
|
||||
def _drone_env() -> dict:
|
||||
"""Env for drone subprocesses (inherits PATH etc.)."""
|
||||
import os
|
||||
|
||||
return dict(os.environ)
|
||||
|
||||
|
||||
def test_t3_drone_systems_lists_branch(clean_venv: CleanVenv, routing_root: Path) -> None:
|
||||
"""``drone systems`` reads the registry and lists a known branch."""
|
||||
proc = _run(
|
||||
[str(clean_venv.drone), "systems"],
|
||||
cwd=str(routing_root),
|
||||
env=_drone_env(),
|
||||
)
|
||||
assert proc.returncode == 0, f"drone systems failed:\n{proc.stdout}\n{proc.stderr}"
|
||||
out = proc.stdout.lower()
|
||||
assert "seedgo" in out or "ai_mail" in out, f"no known branch listed:\n{proc.stdout}"
|
||||
|
||||
|
||||
def test_t3_drone_routes_to_real_branch(clean_venv: CleanVenv, routing_root: Path) -> None:
|
||||
"""``drone @ai_mail --help`` resolves -> subprocesses -> returns help text.
|
||||
|
||||
This is the real integration proof: ai_mail is a registry branch (not an
|
||||
in-process drone module), so a non-empty help body proves drone resolved
|
||||
the @name, found apps/ai_mail.py, ran it in a subprocess, and captured its
|
||||
output.
|
||||
"""
|
||||
proc = _run(
|
||||
[str(clean_venv.drone), "@ai_mail", "--help"],
|
||||
cwd=str(routing_root),
|
||||
env=_drone_env(),
|
||||
)
|
||||
combined = (proc.stdout + proc.stderr).lower()
|
||||
assert proc.returncode == 0, f"drone @ai_mail --help failed:\n{proc.stdout}\n{proc.stderr}"
|
||||
assert proc.stdout.strip(), f"no help text returned:\nSTDOUT:\n{proc.stdout}\nSTDERR:\n{proc.stderr}"
|
||||
assert "ai_mail" in combined or "mail" in combined, f"help text unexpected:\n{proc.stdout}"
|
||||
Reference in New Issue
Block a user