fix(copilot): mirror transactional Python hooks

This commit is contained in:
ZacharyZhang-NY committed 2026-07-20 12:34:09 -04:00
1 parent af8d227b45
commit 9b67aad113
13 files changed
+2120 -583

No files matched your search

+216
View File
@@ -0,0 +1,216 @@
from __future__ import annotations
import base64
from dataclasses import dataclass
import json
import os
from pathlib import Path
import subprocess
import sys
from typing import Any
import pytest
from elydora.plugins import copilot
from elydora.plugins.base import InstallConfig
AGENT_ID = "agent-1"
VALID_PRIVATE_KEY = base64.urlsafe_b64encode(bytes([11]) * 32).rstrip(
b"="
).decode("ascii")
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
user_settings_path: Path
legacy_user_config_path: Path
claude_settings_path: Path
claude_local_settings_path: Path
repository_settings_path: Path
local_settings_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, ensure_ascii=False) + "\n"
)
path.write_text(source, encoding="utf-8")
def prepare_fixture(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
*,
user_config: object = MISSING,
legacy_config: object = MISSING,
user_settings: object = MISSING,
legacy_user_config: object = MISSING,
claude_settings: object = MISSING,
claude_local_settings: object = MISSING,
repository_settings: object = MISSING,
local_settings: object = MISSING,
) -> CopilotFixture:
home_dir = tmp_path / "home with spaces and 'quote %COPILOT%"
project_dir = tmp_path / "project with spaces"
copilot_home = home_dir / "custom Copilot 'home"
agent_dir = home_dir / ".elydora" / AGENT_ID
config_path = copilot_home / "hooks" / "elydora-audit.json"
legacy_path = project_dir / ".github" / "hooks" / "hooks.json"
user_settings_path = copilot_home / "settings.json"
legacy_user_config_path = copilot_home / "config.json"
claude_settings_path = project_dir / ".claude" / "settings.json"
claude_local_settings_path = (
project_dir / ".claude" / "settings.local.json"
)
repository_settings_path = (
project_dir / ".github" / "copilot" / "settings.json"
)
local_settings_path = (
project_dir / ".github" / "copilot" / "settings.local.json"
)
project_dir.mkdir(parents=True)
optional = (
(config_path, user_config),
(legacy_path, legacy_config),
(user_settings_path, user_settings),
(legacy_user_config_path, legacy_user_config),
(claude_settings_path, claude_settings),
(claude_local_settings_path, claude_local_settings),
(repository_settings_path, repository_settings),
(local_settings_path, local_settings),
)
for path, value in optional:
if value is not MISSING:
write_json_or_text(path, value)
monkeypatch.chdir(project_dir)
monkeypatch.setenv("HOME", str(home_dir))
monkeypatch.setenv("USERPROFILE", str(home_dir))
monkeypatch.setenv("COPILOT_HOME", str(copilot_home))
config: InstallConfig = {
"agent_id": AGENT_ID,
"agent_name": "copilot",
"org_id": "org-1",
"private_key": VALID_PRIVATE_KEY,
"kid": "kid-1",
"token": "token-1",
"base_url": "http://127.0.0.1:9",
"guard_script_path": str(agent_dir / "guard.py"),
}
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=agent_dir / "guard.py",
hook_path=agent_dir / "hook.py",
runtime_config_path=agent_dir / "config.json",
private_key_path=agent_dir / "private.key",
user_settings_path=user_settings_path,
legacy_user_config_path=legacy_user_config_path,
claude_settings_path=claude_settings_path,
claude_local_settings_path=claude_local_settings_path,
repository_settings_path=repository_settings_path,
local_settings_path=local_settings_path,
)
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 handler["bash"].startswith("'")
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],
fixture: CopilotFixture,
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,
cwd=fixture.project_dir,
env={
**os.environ,
"HOME": str(fixture.home_dir),
"USERPROFILE": str(fixture.home_dir),
},
input=payload,
text=True,
)
def assert_runtime_absent(fixture: CopilotFixture) -> None:
assert fixture.guard_path.exists() is False
assert fixture.hook_path.exists() is False
assert fixture.runtime_config_path.exists() is False
assert fixture.private_key_path.exists() is False
+3 -2
View File
@@ -390,9 +390,9 @@ def test_legacy_plugins_persist_one_owner_only_private_key(
runtime_root = case_root / ".elydora"
guard_path = runtime_root / "agent-1" / "guard.py"
guard_path.parent.mkdir(parents=True)
if module is not cursor:
if module not in (cursor, copilot):
guard_path.write_text("pass\n", encoding="utf-8")
if module is cursor:
if module in (cursor, copilot):
monkeypatch.setenv("HOME", str(case_root))
monkeypatch.setenv("USERPROFILE", str(case_root))
else:
@@ -410,6 +410,7 @@ def test_legacy_plugins_persist_one_owner_only_private_key(
monkeypatch.setattr(module, "PLUGIN_DIR", str(case_root / "plugins"))
elif module is copilot:
case_root.mkdir(parents=True, exist_ok=True)
monkeypatch.setenv("COPILOT_HOME", str(case_root / ".copilot"))
monkeypatch.chdir(case_root)
plugin_type().install(
+100
View File
@@ -0,0 +1,100 @@
from __future__ import annotations
import json
import os
from pathlib import Path
import shutil
import subprocess
import pytest
from copilot_support import prepare_fixture
COPILOT_ENTRY = os.environ.get("ELYDORA_COPILOT_ENTRY")
COPILOT_RUNTIME_ENTRY = os.environ.get("ELYDORA_COPILOT_RUNTIME_ENTRY")
NODE = shutil.which("node")
@pytest.mark.skipif(
not COPILOT_ENTRY or not COPILOT_RUNTIME_ENTRY or not NODE,
reason=(
"set ELYDORA_COPILOT_ENTRY and ELYDORA_COPILOT_RUNTIME_ENTRY "
"to official package files"
),
)
def test_official_copilot_1_0_71_loads_all_three_managed_hooks(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = prepare_fixture(monkeypatch, tmp_path)
fixture.plugin.install(fixture.config)
environment = {
**os.environ,
"HOME": str(fixture.home_dir),
"USERPROFILE": str(fixture.home_dir),
"COPILOT_HOME": str(fixture.copilot_home),
}
version = subprocess.run(
[str(NODE), str(COPILOT_ENTRY), "--version"],
capture_output=True,
check=False,
cwd=fixture.project_dir,
env=environment,
text=True,
)
assert version.returncode == 0, version.stderr
assert "GitHub Copilot CLI 1.0.71." in version.stdout
source = """
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const runtime = require(process.env.ELYDORA_COPILOT_RUNTIME_ENTRY);
const session = await runtime.hookSessionCreate({
cwd: process.env.ELYDORA_PROJECT,
repoRoot: process.env.ELYDORA_PROJECT,
sessionId: 'elydora-python-official-test',
settingsJson: '{}',
userHooksDir: process.env.ELYDORA_HOOKS,
allowLocalhost: false,
allowHttpAuthHooks: false,
discoverPolicies: false,
});
try {
const snapshot = JSON.parse(await runtime.hookSessionSnapshot(session.handle));
console.log(JSON.stringify({ load: session.load, snapshot }));
} finally {
runtime.hookSessionDispose(session.handle);
}
"""
loaded = subprocess.run(
[str(NODE), "--input-type=module", "--eval", source],
capture_output=True,
check=False,
cwd=fixture.project_dir,
env={
**environment,
"ELYDORA_COPILOT_RUNTIME_ENTRY": str(COPILOT_RUNTIME_ENTRY),
"ELYDORA_HOOKS": str(fixture.config_path.parent),
"ELYDORA_PROJECT": str(fixture.project_dir),
},
text=True,
)
assert loaded.returncode == 0, loaded.stderr
result = json.loads(loaded.stdout)
assert result["load"]["hookCount"] == 3
assert result["load"]["errors"] == []
assert result["load"]["warnings"] == []
hooks = result["snapshot"]["hooks"]
assert sorted(hook["eventName"] for hook in hooks) == [
"postToolUse",
"postToolUseFailure",
"preToolUse",
]
for hook in hooks:
assert Path(hook["source"]).name == "elydora-audit.json"
spec = json.loads(hook["specJson"])
assert spec["config"]["type"] == "command"
assert spec["config"]["timeoutSec"] == 10
assert "python" in spec["config"]["bash"].lower()
assert spec["config"]["powershell"].startswith("& ")
+250 -268
View File
@@ -1,183 +1,39 @@
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 import copilot
from elydora.plugins.registry import SUPPORTED_AGENTS
AGENT_ID = "agent-1"
MISSING = object()
from copilot_support import (
AGENT_ID,
VALID_PRIVATE_KEY,
assert_native_handler,
assert_runtime_absent,
legacy_managed_config,
managed_handler,
prepare_fixture,
run_hook,
write_json_or_text,
)
@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:
def test_copilot_is_registered_with_complete_native_contract() -> None:
assert SUPPORTED_AGENTS["copilot"] == {
"name": "GitHub Copilot CLI",
"hook_event": "preToolUse/postToolUse",
"hook_event": "preToolUse/postToolUse/postToolUseFailure",
"config_path": "~/.copilot/hooks/elydora-audit.json",
}
assert cli.PLUGIN_MAP["copilot"] is copilot.CopilotPlugin
assert copilot.CopilotPlugin.manages_guard_runtime is True
def test_install_preserves_user_hooks_migrates_legacy_and_is_idempotent(
def test_install_preserves_hooks_migrates_legacy_and_is_idempotent(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
@@ -188,15 +44,21 @@ def test_install_preserves_user_hooks_migrates_legacy_and_is_idempotent(
"version": 1,
"disableAllHooks": False,
"hooks": {
"sessionStart": [{"type": "command", "command": "user-session"}],
"preToolUse": [{"type": "command", "command": "user-pre"}],
"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"}],
"notification": [
{"type": "command", "command": "user-notification"}
],
}),
)
@@ -214,18 +76,32 @@ def test_install_preserves_user_hooks_migrates_legacy_and_is_idempotent(
}
assert len(settings["hooks"]["preToolUse"]) == 2
assert len(settings["hooks"]["postToolUse"]) == 1
assert len(settings["hooks"]["postToolUseFailure"]) == 1
assert_native_handler(managed_handler(settings, "preToolUse", "guard.py"))
assert_native_handler(managed_handler(settings, "postToolUse", "hook.py"))
assert_native_handler(
managed_handler(settings, "postToolUseFailure", "hook.py")
)
legacy = json.loads(fixture.legacy_path.read_text(encoding="utf-8"))
assert legacy == {
"version": 1,
"hooks": {
"notification": [{"type": "command", "command": "user-notification"}],
"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"
runtime = json.loads(
fixture.runtime_config_path.read_text(encoding="utf-8")
)
assert runtime["agent_id"] == AGENT_ID
assert runtime["agent_name"] == "copilot"
assert fixture.private_key_path.read_text(encoding="utf-8") == (
VALID_PRIVATE_KEY
)
assert fixture.guard_path.is_file()
assert fixture.hook_path.is_file()
assert fixture.plugin.status()["installed"] is True
def test_migration_removes_legacy_file_owned_entirely_by_elydora(
@@ -242,43 +118,55 @@ def test_migration_removes_legacy_file_owned_entirely_by_elydora(
assert_native_handler(managed_handler(settings, "preToolUse", "guard.py"))
def test_commands_block_and_forward_native_payload_byte_for_byte(
def test_commands_block_and_forward_success_and_failure_payloads(
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.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",
)
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({
base = {
"sessionId": "session-1",
"timestamp": 1,
"cwd": str(fixture.project_dir),
"toolName": "powershell",
"toolArgs": {"command": "Get-ChildItem"},
}, separators=(",", ":"))
}
pre_payload = json.dumps(base, separators=(",", ":"))
guard = managed_handler(settings, "preToolUse", "guard.py")
guard_result = run_hook(guard, fixture.home_dir, pre_payload)
guard_result = run_hook(guard, fixture, 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
success_payload = json.dumps(
{**base, "toolResult": {"resultType": "success", "textResultForLlm": "ok"}},
separators=(",", ":"),
)
success = managed_handler(settings, "postToolUse", "hook.py")
success_result = run_hook(success, fixture, success_payload)
assert success_result.returncode == 0, success_result.stderr
assert capture_path.read_text(encoding="utf-8") == success_payload
failure_payload = json.dumps(
{**base, "error": "command failed"},
separators=(",", ":"),
)
failure = managed_handler(settings, "postToolUseFailure", "hook.py")
failure_result = run_hook(failure, fixture, failure_payload)
assert failure_result.returncode == 0, failure_result.stderr
assert capture_path.read_text(encoding="utf-8") == failure_payload
def test_empty_home_override_uses_official_default(
@@ -287,32 +175,192 @@ def test_empty_home_override_uses_official_default(
) -> 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(
def test_status_requires_complete_contract_and_exact_runtime_sources(
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"]
del settings["hooks"]["postToolUseFailure"]
write_json_or_text(fixture.config_path, settings)
assert fixture.plugin.status()["installed"] is False
fixture.plugin.install(fixture.config)
fixture.guard_path.write_text("# tampered\n", encoding="utf-8")
assert fixture.plugin.status()["installed"] is False
fixture.plugin.install(fixture.config)
valid_runtime = fixture.runtime_config_path.read_text(encoding="utf-8")
fixture.runtime_config_path.write_text("{ malformed", encoding="utf-8")
with pytest.raises(ValueError, match="parse Elydora runtime config"):
with pytest.raises(ValueError, match="runtime config"):
fixture.plugin.status()
fixture.runtime_config_path.write_text(valid_runtime, encoding="utf-8")
fixture.private_key_path.write_text("invalid", encoding="utf-8")
with pytest.raises(ValueError, match="private key"):
fixture.plugin.status()
def test_uninstall_removes_exact_ownership_and_preserves_user_entries(
def test_settings_precedence_allows_later_false_and_preserves_jsonc(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
user_source = "{\n // user policy\n \"disableAllHooks\": true,\n}\n"
claude_source = "{\n \"disableAllHooks\": true,\n}\n"
repository_source = "{\n \"disableAllHooks\": false,\n}\n"
fixture = prepare_fixture(
monkeypatch,
tmp_path,
user_settings=user_source,
claude_local_settings=claude_source,
repository_settings=repository_source,
)
fixture.plugin.install(fixture.config)
assert fixture.user_settings_path.read_text(encoding="utf-8") == user_source
assert fixture.claude_local_settings_path.read_text(
encoding="utf-8"
) == claude_source
assert fixture.repository_settings_path.read_text(
encoding="utf-8"
) == repository_source
assert fixture.plugin.status()["installed"] is True
@pytest.mark.parametrize(
("kwargs", "source_name"),
[
({"repository_settings": {"disableAllHooks": True}}, "repository"),
(
{
"claude_local_settings": {"disableAllHooks": True},
"repository_settings": {"disableAllHooks": True},
},
"repository",
),
],
)
def test_effective_disabled_settings_block_before_writes(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
kwargs: dict[str, object],
source_name: str,
) -> None:
fixture = prepare_fixture(monkeypatch, tmp_path, **kwargs)
with pytest.raises(ValueError, match=source_name):
fixture.plugin.install(fixture.config)
assert fixture.config_path.exists() is False
assert_runtime_absent(fixture)
def test_managed_file_disable_flag_remains_authoritative(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = prepare_fixture(
monkeypatch,
tmp_path,
user_config={"version": 1, "disableAllHooks": True, "hooks": {}},
local_settings={"disableAllHooks": False},
)
with pytest.raises(ValueError, match="user hooks"):
fixture.plugin.install(fixture.config)
assert_runtime_absent(fixture)
def test_matchers_use_official_javascript_regular_expression_syntax(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = prepare_fixture(
monkeypatch,
tmp_path,
user_config={
"version": 1,
"hooks": {
"preToolUse": [{
"type": "command",
"command": "user-pre",
"matcher": "(?<tool>shell)",
}],
},
},
)
fixture.plugin.install(fixture.config)
settings = json.loads(fixture.config_path.read_text(encoding="utf-8"))
assert settings["hooks"]["preToolUse"][0]["matcher"] == (
"(?<tool>shell)"
)
@pytest.mark.parametrize(
"existing",
[
"{ malformed",
'{"version":1,"version":1,"hooks":{}}',
{"hooks": {}},
{"version": True, "hooks": {}},
{"version": 1, "hooks": None},
{"version": 1, "hooks": {"unknownEvent": []}},
{"version": 1, "hooks": {"preToolUse": None}},
{"version": 1, "hooks": {"preToolUse": [None]}},
{"version": 1, "hooks": {"preToolUse": [{}]}},
{"version": 1, "hooks": {"preToolUse": [{"command": 1}]}},
{"version": 1, "hooks": {"preToolUse": [{"command": "x", "timeoutSec": 0}]}},
{"version": 1, "hooks": {"preToolUse": [{"command": "x", "matcher": "["}]}},
{"version": 1, "hooks": {"postToolUse": [{"type": "prompt", "prompt": "x"}]}},
{"version": 1, "hooks": {"postToolUse": [{"type": "http", "url": "http://example.com"}]}},
],
)
def test_invalid_hook_documents_are_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):
fixture.plugin.install(fixture.config)
assert fixture.config_path.read_text(encoding="utf-8") == original
assert_runtime_absent(fixture)
@pytest.mark.parametrize(
"settings",
[
"{ malformed",
'{"disableAllHooks":true,"disableAllHooks":false}',
'{"disableAllHooks":"yes"}',
],
)
def test_invalid_settings_are_preserved_before_runtime_writes(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
settings: str,
) -> None:
fixture = prepare_fixture(monkeypatch, tmp_path, user_settings=settings)
with pytest.raises(ValueError):
fixture.plugin.install(fixture.config)
assert fixture.user_settings_path.read_text(encoding="utf-8") == settings
assert_runtime_absent(fixture)
def test_uninstall_removes_exact_ownership_and_preserves_adjacent_handlers(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
@@ -328,8 +376,8 @@ def test_uninstall_removes_exact_ownership_and_preserves_user_entries(
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",
"bash": "user-pre",
"powershell": "user-pre",
"timeoutSec": 10,
})
write_json_or_text(fixture.config_path, settings)
@@ -340,9 +388,14 @@ def test_uninstall_removes_exact_ownership_and_preserves_user_entries(
assert remaining["hooks"]["notification"] == [
{"type": "command", "command": "keep"}
]
assert remaining["hooks"]["preToolUse"] == [{
"type": "command",
"bash": "user-pre",
"powershell": "user-pre",
"timeoutSec": 10,
}]
assert "postToolUse" not in remaining["hooks"]
assert len(remaining["hooks"]["preToolUse"]) == 1
assert "agent-10" in remaining["hooks"]["preToolUse"][0]["bash"]
assert "postToolUseFailure" not in remaining["hooks"]
def test_uninstall_leaves_absent_sources_absent(
@@ -355,85 +408,14 @@ def test_uninstall_leaves_absent_sources_absent(
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(
def test_preflight_validates_credentials_and_runtime_path_without_writes(
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()
)
invalid = dict(fixture.config)
invalid["private_key"] = "invalid"
with pytest.raises(ValueError, match="private_key"):
fixture.plugin.preflight_install(invalid) # type: ignore[arg-type]
assert fixture.config_path.exists() is False
assert_runtime_absent(fixture)
@@ -0,0 +1,238 @@
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any
import pytest
from elydora.plugins import _transaction, copilot
from elydora.plugins.copilot_installation import prepare_copilot_installation
from elydora.plugins.copilot_io import read_sources
from copilot_support import (
AGENT_ID,
assert_runtime_absent,
legacy_managed_config,
prepare_fixture,
write_json_or_text,
)
def test_install_rolls_back_runtime_hooks_and_legacy_after_late_failure(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = prepare_fixture(
monkeypatch,
tmp_path,
user_config={"version": 1, "hooks": {"notification": []}},
)
write_json_or_text(fixture.legacy_path, legacy_managed_config(fixture))
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_fifth_commit(staged: Any) -> None:
nonlocal commits
original_commit(staged)
commits += 1
if commits == 5:
raise OSError("injected late commit failure")
monkeypatch.setattr(_transaction, "_commit", fail_after_fifth_commit)
with pytest.raises(OSError, match="injected late 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_runtime_absent(fixture)
def test_install_detects_concurrent_hook_replacement_before_its_commit(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = prepare_fixture(
monkeypatch,
tmp_path,
user_config={"version": 1, "hooks": {"notification": []}},
)
concurrent = '{"version":1,"hooks":{"notification":[]},"external":true}\n'
original_commit = _transaction._commit
commits = 0
def replace_after_first_commit(staged: Any) -> None:
nonlocal commits
original_commit(staged)
commits += 1
if commits == 1:
fixture.config_path.write_text(concurrent, encoding="utf-8")
monkeypatch.setattr(_transaction, "_commit", replace_after_first_commit)
with pytest.raises(OSError, match="changed during installation"):
fixture.plugin.install(fixture.config)
assert fixture.config_path.read_text(encoding="utf-8") == concurrent
assert_runtime_absent(fixture)
def test_install_rolls_back_when_effective_settings_change_mid_transaction(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = prepare_fixture(
monkeypatch,
tmp_path,
user_settings={"disableAllHooks": False},
)
original_commit = _transaction._commit
commits = 0
def disable_after_third_commit(staged: Any) -> None:
nonlocal commits
original_commit(staged)
commits += 1
if commits == 3:
write_json_or_text(
fixture.user_settings_path,
{"disableAllHooks": True},
)
monkeypatch.setattr(_transaction, "_commit", disable_after_third_commit)
with pytest.raises(OSError, match="user settings changed"):
fixture.plugin.install(fixture.config)
assert json.loads(
fixture.user_settings_path.read_text(encoding="utf-8")
)["disableAllHooks"] is True
assert fixture.config_path.exists() is False
assert_runtime_absent(fixture)
def test_prepare_rejects_same_content_stale_hook_snapshot(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = prepare_fixture(
monkeypatch,
tmp_path,
user_config={"version": 1, "hooks": {"notification": []}},
)
sources = read_sources()
paths = copilot.preflight_copilot_installation(fixture.config, sources)
rendered = copilot._render_installation(
sources,
paths.guard_path,
paths.audit_path,
)
original = fixture.config_path.read_text(encoding="utf-8")
replacement = fixture.config_path.with_name("replacement.json")
replacement.write_text(original, encoding="utf-8")
os.replace(replacement, fixture.config_path)
with pytest.raises(OSError, match="changed before staging"):
prepare_copilot_installation(fixture.config, sources, rendered)
assert_runtime_absent(fixture)
def test_install_preserves_orphaned_runtime_without_verifiable_identity(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = prepare_fixture(monkeypatch, tmp_path)
fixture.agent_dir.mkdir(parents=True)
fixture.hook_path.write_text("# orphaned\n", encoding="utf-8")
with pytest.raises(ValueError, match="cannot be verified"):
fixture.plugin.install(fixture.config)
assert fixture.hook_path.read_text(encoding="utf-8") == "# orphaned\n"
assert fixture.config_path.exists() is False
assert fixture.guard_path.exists() is False
assert fixture.runtime_config_path.exists() is False
assert fixture.private_key_path.exists() is False
def _symlink_or_skip(
target: Path,
link: Path,
*,
directory: bool,
) -> None:
try:
link.parent.mkdir(parents=True, exist_ok=True)
os.symlink(target, link, target_is_directory=directory)
except (NotImplementedError, OSError) as error:
pytest.skip(f"symbolic links unavailable: {error}")
@pytest.mark.parametrize("location", ["home", "hook", "runtime"])
def test_install_rejects_linked_hook_and_runtime_paths(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
location: str,
) -> None:
fixture = prepare_fixture(monkeypatch, tmp_path)
if location == "home":
target = tmp_path / "copilot-target"
target.mkdir()
linked = tmp_path / "linked-copilot-home"
_symlink_or_skip(target, linked, directory=True)
monkeypatch.setenv("COPILOT_HOME", str(linked))
elif location == "hook":
target = tmp_path / "hook-target.json"
write_json_or_text(target, {"version": 1, "hooks": {}})
_symlink_or_skip(target, fixture.config_path, directory=False)
else:
target = tmp_path / "runtime-target"
target.mkdir()
fixture.agent_dir.parent.mkdir(parents=True)
_symlink_or_skip(target, fixture.agent_dir, directory=True)
with pytest.raises(OSError, match="physical"):
fixture.plugin.install(fixture.config)
def test_uninstall_restores_both_hook_documents_when_second_commit_fails(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
fixture = prepare_fixture(monkeypatch, tmp_path)
fixture.plugin.install(fixture.config)
managed = fixture.config_path.read_text(encoding="utf-8")
fixture.legacy_path.parent.mkdir(parents=True, exist_ok=True)
fixture.legacy_path.write_text(managed, encoding="utf-8")
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_second_commit(staged: Any) -> None:
nonlocal commits
original_commit(staged)
commits += 1
if commits == 2:
raise OSError("injected uninstall failure")
monkeypatch.setattr(_transaction, "_commit", fail_after_second_commit)
with pytest.raises(OSError, match="injected uninstall failure"):
fixture.plugin.uninstall(AGENT_ID)
assert fixture.config_path.read_text(encoding="utf-8") == user_before
assert fixture.legacy_path.read_text(encoding="utf-8") == legacy_before
def test_installation_leaves_no_transaction_artifacts(
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()
)