fix(copilot): make Python hook installation transactional
This commit is contained in:
1 parent
97d1525227
commit
16ceac81c4
14 files changed
+2121
-583
No files matched your search
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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()
|
||||
)
|
||||
Reference in new issue
Block a user