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
|
### 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
|
- **`drone rm` — provider-agnostic safe delete** — a contained recursive delete
|
||||||
that lets agents clean up scratch dirs without tripping the `rm -rf` block.
|
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
|
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