Files
Elydora-Python-SDK/tests/test_copilot_plugin.py
T

440 lines
15 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
import json
import os
from pathlib import Path
import subprocess
import sys
from typing import Any
import pytest
from elydora import cli
from elydora.plugins import _transaction, copilot
from elydora.plugins.base import InstallConfig
from elydora.plugins.registry import SUPPORTED_AGENTS
AGENT_ID = "agent-1"
MISSING = object()
@dataclass(frozen=True)
class CopilotFixture:
plugin: copilot.CopilotPlugin
config: InstallConfig
home_dir: Path
copilot_home: Path
project_dir: Path
agent_dir: Path
config_path: Path
legacy_path: Path
guard_path: Path
hook_path: Path
runtime_config_path: Path
private_key_path: Path
def write_json_or_text(path: Path, value: object) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
source = value if isinstance(value, str) else json.dumps(value, indent=2)
path.write_text(source, encoding="utf-8")
def prepare_fixture(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
*,
user_config: object = MISSING,
legacy_config: object = MISSING,
create_guard: bool = True,
) -> CopilotFixture:
home_dir = tmp_path / "home with spaces"
project_dir = tmp_path / "project with spaces"
copilot_home = home_dir / "custom copilot"
agent_dir = home_dir / ".elydora" / AGENT_ID
config_path = copilot_home / "hooks" / "elydora-audit.json"
legacy_path = project_dir / ".github" / "hooks" / "hooks.json"
guard_path = agent_dir / "guard.py"
hook_path = agent_dir / "hook.py"
agent_dir.mkdir(parents=True)
project_dir.mkdir(parents=True)
if create_guard:
guard_path.write_text(
"import sys\nsys.stdin.read()\n"
"sys.stderr.write('Agent is frozen by Elydora.')\n"
"raise SystemExit(2)\n",
encoding="utf-8",
)
if user_config is not MISSING:
write_json_or_text(config_path, user_config)
if legacy_config is not MISSING:
write_json_or_text(legacy_path, legacy_config)
monkeypatch.chdir(project_dir)
monkeypatch.setattr(copilot, "ELYDORA_DIR", str(agent_dir.parent))
monkeypatch.setattr(copilot, "_home_dir", lambda: str(home_dir), raising=False)
monkeypatch.setenv("COPILOT_HOME", str(copilot_home))
config: InstallConfig = {
"agent_id": AGENT_ID,
"agent_name": "copilot",
"org_id": "org-1",
"private_key": "test-key",
"kid": "kid-1",
"base_url": "https://api.elydora.test",
"guard_script_path": str(guard_path),
}
return CopilotFixture(
plugin=copilot.CopilotPlugin(),
config=config,
home_dir=home_dir,
copilot_home=copilot_home,
project_dir=project_dir,
agent_dir=agent_dir,
config_path=config_path,
legacy_path=legacy_path,
guard_path=guard_path,
hook_path=hook_path,
runtime_config_path=agent_dir / "config.json",
private_key_path=agent_dir / "private.key",
)
def managed_handler(
settings: dict[str, Any], event: str, script_name: str
) -> dict[str, Any]:
for handler in settings.get("hooks", {}).get(event, []):
if script_name in str(handler.get("bash")):
return handler
raise AssertionError(f"managed {event} handler not found")
def assert_native_handler(handler: dict[str, Any]) -> None:
assert set(handler) == {"type", "bash", "powershell", "timeoutSec"}
assert handler["type"] == "command"
assert handler["timeoutSec"] == 10
assert Path(sys.executable).name in handler["bash"]
assert handler["powershell"].startswith("& ")
assert handler["powershell"].endswith("; exit $LASTEXITCODE")
def legacy_managed_config(
fixture: CopilotFixture,
extra_hooks: dict[str, Any] | None = None,
) -> dict[str, Any]:
return {
"version": 1,
"hooks": {
"preToolUse": [{
"type": "command",
"bash": f'"{sys.executable}" {fixture.guard_path}',
"powershell": f'"{sys.executable}" {fixture.guard_path}',
"timeoutSec": 5,
}],
"postToolUse": [{
"type": "command",
"bash": str(fixture.hook_path),
"powershell": str(fixture.hook_path),
"timeoutSec": 5,
}],
**(extra_hooks or {}),
},
}
def run_hook(
handler: dict[str, Any],
home_dir: Path,
payload: str,
) -> subprocess.CompletedProcess[str]:
if os.name == "nt":
command = [
"powershell.exe",
"-NoProfile",
"-NonInteractive",
"-Command",
handler["powershell"],
]
else:
command = ["/bin/sh", "-c", handler["bash"]]
return subprocess.run(
command,
capture_output=True,
check=False,
env={**os.environ, "HOME": str(home_dir), "USERPROFILE": str(home_dir)},
input=payload,
text=True,
)
def test_copilot_is_registered_with_native_user_hooks() -> None:
assert SUPPORTED_AGENTS["copilot"] == {
"name": "GitHub Copilot CLI",
"hook_event": "preToolUse/postToolUse",
"config_path": "~/.copilot/hooks/elydora-audit.json",
}
assert cli.PLUGIN_MAP["copilot"] is copilot.CopilotPlugin
def test_install_preserves_user_hooks_migrates_legacy_and_is_idempotent(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = prepare_fixture(
monkeypatch,
tmp_path,
user_config={
"version": 1,
"disableAllHooks": False,
"hooks": {
"sessionStart": [{"type": "command", "command": "user-session"}],
"preToolUse": [{"type": "command", "command": "user-pre"}],
},
},
)
write_json_or_text(
fixture.legacy_path,
legacy_managed_config(fixture, {
"notification": [{"type": "command", "command": "user-notification"}],
}),
)
fixture.plugin.install(fixture.config)
fixture.plugin.install(fixture.config)
settings = json.loads(fixture.config_path.read_text(encoding="utf-8"))
assert settings["disableAllHooks"] is False
assert settings["hooks"]["sessionStart"] == [
{"type": "command", "command": "user-session"}
]
assert settings["hooks"]["preToolUse"][0] == {
"type": "command",
"command": "user-pre",
}
assert len(settings["hooks"]["preToolUse"]) == 2
assert len(settings["hooks"]["postToolUse"]) == 1
assert_native_handler(managed_handler(settings, "preToolUse", "guard.py"))
assert_native_handler(managed_handler(settings, "postToolUse", "hook.py"))
legacy = json.loads(fixture.legacy_path.read_text(encoding="utf-8"))
assert legacy == {
"version": 1,
"hooks": {
"notification": [{"type": "command", "command": "user-notification"}],
},
}
assert fixture.runtime_config_path.is_file()
assert fixture.private_key_path.read_text(encoding="utf-8") == "test-key"
assert fixture.hook_path.is_file()
def test_migration_removes_legacy_file_owned_entirely_by_elydora(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = prepare_fixture(monkeypatch, tmp_path)
write_json_or_text(fixture.legacy_path, legacy_managed_config(fixture))
fixture.plugin.install(fixture.config)
assert fixture.legacy_path.exists() is False
settings = json.loads(fixture.config_path.read_text(encoding="utf-8"))
assert_native_handler(managed_handler(settings, "preToolUse", "guard.py"))
def test_commands_block_and_forward_native_payload_byte_for_byte(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = prepare_fixture(monkeypatch, tmp_path)
fixture.plugin.install(fixture.config)
capture_path = tmp_path / "captured-event.json"
fixture.hook_path.write_text(
"from pathlib import Path\nimport sys\n"
f"Path({str(capture_path)!r}).write_text(sys.stdin.read(), encoding='utf-8')\n",
encoding="utf-8",
)
settings = json.loads(fixture.config_path.read_text(encoding="utf-8"))
pre_payload = json.dumps({
"sessionId": "session-1",
"timestamp": 1,
"cwd": str(fixture.project_dir),
"toolName": "powershell",
"toolArgs": {"command": "Get-ChildItem"},
}, separators=(",", ":"))
guard = managed_handler(settings, "preToolUse", "guard.py")
guard_result = run_hook(guard, fixture.home_dir, pre_payload)
assert guard_result.returncode == 2, guard_result.stderr
assert "Agent is frozen by Elydora" in guard_result.stderr
post_payload = json.dumps({
"sessionId": "session-1",
"timestamp": 2,
"cwd": str(fixture.project_dir),
"toolName": "powershell",
"toolArgs": {"command": "Get-ChildItem"},
"toolResult": {"output": "ok"},
}, separators=(",", ":"))
audit = managed_handler(settings, "postToolUse", "hook.py")
audit_result = run_hook(audit, fixture.home_dir, post_payload)
assert audit_result.returncode == 0, audit_result.stderr
assert capture_path.read_text(encoding="utf-8") == post_payload
def test_empty_home_override_uses_official_default(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = prepare_fixture(monkeypatch, tmp_path)
monkeypatch.setenv("COPILOT_HOME", "")
fixture.plugin.install(fixture.config)
default_path = fixture.home_dir / ".copilot" / "hooks" / "elydora-audit.json"
settings = json.loads(default_path.read_text(encoding="utf-8"))
assert_native_handler(managed_handler(settings, "preToolUse", "guard.py"))
assert str(default_path) in fixture.plugin.status()["details"]
def test_status_requires_complete_pair_and_valid_runtime_identity(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = prepare_fixture(monkeypatch, tmp_path)
fixture.plugin.install(fixture.config)
assert fixture.plugin.status()["installed"] is True
settings = json.loads(fixture.config_path.read_text(encoding="utf-8"))
del settings["hooks"]["postToolUse"]
write_json_or_text(fixture.config_path, settings)
assert fixture.plugin.status()["installed"] is False
fixture.plugin.install(fixture.config)
fixture.runtime_config_path.write_text("{ malformed", encoding="utf-8")
with pytest.raises(ValueError, match="parse Elydora runtime config"):
fixture.plugin.status()
def test_uninstall_removes_exact_ownership_and_preserves_user_entries(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = prepare_fixture(
monkeypatch,
tmp_path,
user_config={
"version": 1,
"hooks": {"notification": [{"type": "command", "command": "keep"}]},
},
)
fixture.plugin.install(fixture.config)
settings = json.loads(fixture.config_path.read_text(encoding="utf-8"))
settings["hooks"]["preToolUse"].append({
"type": "command",
"bash": f"{sys.executable} {fixture.agent_dir.parent / 'agent-10' / 'guard.py'}",
"powershell": "user-decoy",
"timeoutSec": 10,
})
write_json_or_text(fixture.config_path, settings)
fixture.plugin.uninstall(AGENT_ID.upper() if os.name == "nt" else AGENT_ID)
remaining = json.loads(fixture.config_path.read_text(encoding="utf-8"))
assert remaining["hooks"]["notification"] == [
{"type": "command", "command": "keep"}
]
assert "postToolUse" not in remaining["hooks"]
assert len(remaining["hooks"]["preToolUse"]) == 1
assert "agent-10" in remaining["hooks"]["preToolUse"][0]["bash"]
def test_uninstall_leaves_absent_sources_absent(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = prepare_fixture(monkeypatch, tmp_path)
fixture.plugin.uninstall(AGENT_ID)
assert fixture.config_path.exists() is False
assert fixture.legacy_path.exists() is False
@pytest.mark.parametrize(
"existing",
[
"{ malformed",
{"hooks": {}},
{"version": 2, "hooks": {}},
{"version": 1, "hooks": None},
{"version": 1, "hooks": {"preToolUse": None}},
{"version": 1, "hooks": {"preToolUse": [None]}},
],
)
def test_invalid_config_is_preserved_before_runtime_writes(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
existing: object,
) -> None:
fixture = prepare_fixture(monkeypatch, tmp_path, user_config=existing)
original = fixture.config_path.read_text(encoding="utf-8")
with pytest.raises((ValueError, json.JSONDecodeError)):
fixture.plugin.install(fixture.config)
assert fixture.config_path.read_text(encoding="utf-8") == original
assert fixture.runtime_config_path.exists() is False
assert fixture.private_key_path.exists() is False
assert fixture.hook_path.exists() is False
def test_missing_guard_is_rejected_before_creating_files(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = prepare_fixture(monkeypatch, tmp_path, create_guard=False)
with pytest.raises(FileNotFoundError, match="guard runtime is missing"):
fixture.plugin.install(fixture.config)
assert fixture.config_path.exists() is False
assert fixture.runtime_config_path.exists() is False
assert fixture.private_key_path.exists() is False
assert fixture.hook_path.exists() is False
def test_transaction_rolls_back_every_file_after_commit_failure(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = prepare_fixture(
monkeypatch,
tmp_path,
user_config={"version": 1, "hooks": {"notification": []}},
legacy_config={"version": 1, "hooks": {"sessionStart": []}},
)
user_before = fixture.config_path.read_text(encoding="utf-8")
legacy_before = fixture.legacy_path.read_text(encoding="utf-8")
original_commit = _transaction._commit
commits = 0
def fail_after_first_commit(staged: Any) -> None:
nonlocal commits
original_commit(staged)
commits += 1
if commits == 1:
raise OSError("injected commit failure")
monkeypatch.setattr(_transaction, "_commit", fail_after_first_commit)
with pytest.raises(OSError, match="injected commit failure"):
fixture.plugin.install(fixture.config)
assert fixture.config_path.read_text(encoding="utf-8") == user_before
assert fixture.legacy_path.read_text(encoding="utf-8") == legacy_before
assert fixture.runtime_config_path.exists() is False
assert fixture.private_key_path.exists() is False
assert fixture.hook_path.exists() is False
def test_atomic_writes_leave_no_transaction_files(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = prepare_fixture(monkeypatch, tmp_path)
fixture.plugin.install(fixture.config)
for directory in (fixture.agent_dir, fixture.config_path.parent):
assert all(
path.suffix not in {".tmp", ".rollback"}
for path in directory.iterdir()
)