488 lines
16 KiB
Python
488 lines
16 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import sys
|
|
import time
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from elydora import cli
|
|
from elydora.plugins import droid
|
|
from elydora.plugins.registry import SUPPORTED_AGENTS
|
|
|
|
from droid_support import (
|
|
AGENT_ID,
|
|
VALID_PRIVATE_KEY,
|
|
ApiServer,
|
|
DroidFixture,
|
|
assert_native_group,
|
|
assert_no_transaction_files,
|
|
load_jsonc,
|
|
managed_group,
|
|
managed_handler,
|
|
read_raw,
|
|
run_hook,
|
|
snapshot,
|
|
write_json,
|
|
)
|
|
|
|
|
|
def _current_hooks(path: Path) -> dict[str, Any]:
|
|
return load_jsonc(path)["hooks"]
|
|
|
|
|
|
def test_droid_is_registered_in_the_sdk_and_cli() -> None:
|
|
assert SUPPORTED_AGENTS["droid"] == {
|
|
"name": "Factory Droid",
|
|
"hook_event": "PreToolUse/PostToolUse",
|
|
"config_path": "~/.factory/hooks.json",
|
|
}
|
|
assert cli.PLUGIN_MAP["droid"] is droid.DroidPlugin
|
|
|
|
|
|
def test_install_writes_current_container_and_complete_runtime(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
capsys: pytest.CaptureFixture[str],
|
|
) -> None:
|
|
fixture = DroidFixture(monkeypatch, tmp_path)
|
|
fixture.install()
|
|
output = capsys.readouterr().out
|
|
source = fixture.root_path.read_text(encoding="utf-8")
|
|
assert source.startswith("// Managed by Elydora\n")
|
|
hooks = _current_hooks(fixture.root_path)
|
|
assert_native_group(managed_group(hooks, "PreToolUse", "guard.py"))
|
|
assert_native_group(managed_group(hooks, "PostToolUse", "hook.py"))
|
|
guard = managed_handler(hooks, "PreToolUse", "guard.py")
|
|
assert guard is not None
|
|
if os.name == "nt":
|
|
assert guard["command"].startswith("& '")
|
|
assert guard["command"].endswith("; exit $LASTEXITCODE")
|
|
runtime = json.loads(fixture.runtime_config.read_text(encoding="utf-8"))
|
|
assert runtime == {
|
|
"org_id": "org-1",
|
|
"agent_id": AGENT_ID,
|
|
"kid": "kid-1",
|
|
"base_url": "http://127.0.0.1:9",
|
|
"agent_name": "droid",
|
|
"token": "token-1",
|
|
}
|
|
assert fixture.private_key_path.read_text(encoding="utf-8") == VALID_PRIVATE_KEY
|
|
assert "AGENT_NAME = 'droid'" in fixture.guard_path.read_text(encoding="utf-8")
|
|
assert "NATIVE_PAYLOAD = True" in fixture.audit_path.read_text(encoding="utf-8")
|
|
assert "run /hooks" in output
|
|
|
|
paths = (
|
|
fixture.root_path,
|
|
fixture.guard_path,
|
|
fixture.audit_path,
|
|
fixture.runtime_config,
|
|
fixture.private_key_path,
|
|
)
|
|
before = snapshot(paths)
|
|
fixture.install()
|
|
assert snapshot(paths) == before
|
|
assert_no_transaction_files(tmp_path)
|
|
|
|
|
|
@pytest.mark.skipif(os.name != "nt", reason="Windows migration contract")
|
|
def test_install_migrates_legacy_windows_commands(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
fixture = DroidFixture(monkeypatch, tmp_path, root_config={"hooks": {}})
|
|
|
|
def group(script: Path) -> dict[str, Any]:
|
|
return {
|
|
"matcher": "*",
|
|
"hooks": [{
|
|
"type": "command",
|
|
"command": f'"{sys.executable}" "{script}"',
|
|
"timeout": 10,
|
|
}],
|
|
}
|
|
|
|
write_json(fixture.root_path, {
|
|
"hooks": {
|
|
"PreToolUse": [group(fixture.guard_path)],
|
|
"PostToolUse": [group(fixture.audit_path)],
|
|
},
|
|
})
|
|
fixture.install()
|
|
hooks = _current_hooks(fixture.root_path)
|
|
assert len(hooks["PreToolUse"]) == 1
|
|
assert len(hooks["PostToolUse"]) == 1
|
|
assert hooks["PreToolUse"][0]["hooks"][0]["command"].startswith("& '")
|
|
|
|
|
|
def test_root_hook_file_has_whole_source_precedence(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
root = """{
|
|
// active root source
|
|
"hooks": {
|
|
"PreToolUse": [
|
|
{ "matcher": "Read", "hooks": [{ "type": "command", "command": "root-user" }] }
|
|
]
|
|
}
|
|
}
|
|
"""
|
|
settings = """{
|
|
// inactive settings source
|
|
"theme": "dark",
|
|
"hooks": {
|
|
"PostToolUse": [
|
|
{ "matcher": "Edit", "hooks": [{ "type": "command", "command": "settings-user" }] }
|
|
]
|
|
}
|
|
}
|
|
"""
|
|
fixture = DroidFixture(
|
|
monkeypatch,
|
|
tmp_path,
|
|
root_config=root,
|
|
settings=settings,
|
|
)
|
|
fixture.install()
|
|
root_source = fixture.root_path.read_text(encoding="utf-8")
|
|
hooks = _current_hooks(fixture.root_path)
|
|
assert "active root source" in root_source
|
|
assert hooks["PreToolUse"][0]["hooks"][0]["command"] == "root-user"
|
|
assert_native_group(managed_group(hooks, "PreToolUse", "guard.py"))
|
|
assert_native_group(managed_group(hooks, "PostToolUse", "hook.py"))
|
|
assert fixture.settings_path.read_text(encoding="utf-8") == settings
|
|
|
|
|
|
def test_settings_and_local_settings_follow_source_precedence(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
settings = '{\r\n\t"theme": "dark",\r\n\t"hooks": {}\r\n}\r\n'
|
|
fixture = DroidFixture(monkeypatch, tmp_path / "base", settings=settings)
|
|
fixture.install()
|
|
assert not fixture.root_path.exists()
|
|
source = read_raw(fixture.settings_path)
|
|
hooks = load_jsonc(fixture.settings_path)["hooks"]
|
|
assert '\r\n\t\t"PreToolUse"' in source
|
|
assert_native_group(managed_group(hooks, "PreToolUse", "guard.py"))
|
|
assert_native_group(managed_group(hooks, "PostToolUse", "hook.py"))
|
|
|
|
local_fixture = DroidFixture(
|
|
monkeypatch,
|
|
tmp_path / "local",
|
|
settings={"hooks": {"Notification": []}},
|
|
local_settings={"hooks": {"SessionStart": []}},
|
|
)
|
|
base_before = local_fixture.settings_path.read_text(encoding="utf-8")
|
|
local_fixture.install()
|
|
assert local_fixture.settings_path.read_text(encoding="utf-8") == base_before
|
|
local_hooks = load_jsonc(local_fixture.local_settings_path)["hooks"]
|
|
assert_native_group(managed_group(local_hooks, "PreToolUse", "guard.py"))
|
|
assert_native_group(managed_group(local_hooks, "PostToolUse", "hook.py"))
|
|
|
|
|
|
def test_legacy_hook_file_stays_active_until_factory_migrates_it(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
fixture = DroidFixture(
|
|
monkeypatch,
|
|
tmp_path,
|
|
legacy_config={
|
|
"PreToolUse": [{
|
|
"matcher": "Read",
|
|
"hooks": [{"type": "command", "command": "legacy-user"}],
|
|
}],
|
|
},
|
|
settings={"hooks": {"PostToolUse": []}},
|
|
)
|
|
settings_before = fixture.settings_path.read_text(encoding="utf-8")
|
|
fixture.install()
|
|
assert not fixture.root_path.exists()
|
|
legacy = load_jsonc(fixture.legacy_path)
|
|
assert_native_group(managed_group(legacy, "PreToolUse", "guard.py"))
|
|
assert_native_group(managed_group(legacy, "PostToolUse", "hook.py"))
|
|
assert fixture.settings_path.read_text(encoding="utf-8") == settings_before
|
|
|
|
|
|
def test_guard_blocks_and_audit_preserves_native_payload(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
api = ApiServer()
|
|
try:
|
|
fixture = DroidFixture(
|
|
monkeypatch,
|
|
tmp_path,
|
|
base_url=api.base_url,
|
|
)
|
|
fixture.install()
|
|
hooks = _current_hooks(fixture.root_path)
|
|
guard = managed_handler(hooks, "PreToolUse", "guard.py")
|
|
audit = managed_handler(hooks, "PostToolUse", "hook.py")
|
|
assert guard is not None and audit is not None
|
|
write_json(fixture.agent_dir / "status-cache.json", {
|
|
"status": "frozen",
|
|
"cached_at": time.time(),
|
|
})
|
|
pre_payload = {
|
|
"session_id": "session-1",
|
|
"transcript_path": str(fixture.home_dir / "transcript.jsonl"),
|
|
"cwd": str(fixture.workspace_dir),
|
|
"permission_mode": "auto-high",
|
|
"hook_event_name": "PreToolUse",
|
|
"tool_name": "Execute",
|
|
"tool_input": {"command": "echo test"},
|
|
}
|
|
guard_result = run_hook(guard["command"], json.dumps(pre_payload))
|
|
assert guard_result.returncode == 2
|
|
assert 'Agent "droid" is frozen' in guard_result.stderr
|
|
|
|
post_payload = {
|
|
**pre_payload,
|
|
"hook_event_name": "PostToolUse",
|
|
"tool_response": {"output": "test", "success": True},
|
|
}
|
|
audit_result = run_hook(audit["command"], json.dumps(post_payload))
|
|
assert audit_result.returncode == 0, audit_result.stderr
|
|
request = next(item for item in api.requests if item[0] == "POST")
|
|
operation = json.loads(request[2])
|
|
assert operation["payload"] == post_payload
|
|
assert operation["subject"] == {"session_id": "session-1"}
|
|
assert operation["action"] == {"tool": "Execute"}
|
|
finally:
|
|
api.close()
|
|
|
|
|
|
def test_status_requires_exact_hooks_and_runtime_sources(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
fixture = DroidFixture(monkeypatch, tmp_path)
|
|
fixture.install()
|
|
assert fixture.plugin.status() == {
|
|
"installed": True,
|
|
"agent": "droid",
|
|
"details": f"Config: {fixture.root_path}",
|
|
}
|
|
fixture.audit_path.write_text("tampered\n", encoding="utf-8")
|
|
status = fixture.plugin.status()
|
|
assert status["installed"] is False
|
|
assert "incomplete" in status["details"]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("mutation", "pattern"),
|
|
[
|
|
("config", "parse Elydora runtime config"),
|
|
("key", "canonical 32-byte"),
|
|
],
|
|
)
|
|
def test_status_surfaces_malformed_runtime_identity(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
mutation: str,
|
|
pattern: str,
|
|
) -> None:
|
|
fixture = DroidFixture(monkeypatch, tmp_path)
|
|
fixture.install()
|
|
target = fixture.runtime_config if mutation == "config" else fixture.private_key_path
|
|
target.write_text("{ malformed" if mutation == "config" else "invalid", encoding="utf-8")
|
|
with pytest.raises(ValueError, match=pattern):
|
|
fixture.plugin.status()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("root_config", "legacy_config", "settings", "local_settings", "pattern"),
|
|
[
|
|
("{ malformed", None, None, None, "parse Factory Droid hooks"),
|
|
([], None, None, None, "JSON object"),
|
|
('{"hooks":{"PreToolUse":[],"PreToolUse":[]}}', None, None, None, "duplicate"),
|
|
({"hooks": {"PreToolUse": None}}, None, None, None, "must be an array"),
|
|
({"hooks": {"PreToolUse": [None]}}, None, None, None, "must be an object"),
|
|
(
|
|
{"hooks": {"PreToolUse": [{"matcher": "[", "hooks": []}]}},
|
|
None,
|
|
None,
|
|
None,
|
|
"regular expression",
|
|
),
|
|
(
|
|
{"hooks": {"PreToolUse": [{"hooks": [{
|
|
"type": "command",
|
|
"command": "",
|
|
}]}]}},
|
|
None,
|
|
None,
|
|
None,
|
|
"non-empty",
|
|
),
|
|
(
|
|
{"hooks": {"PreToolUse": [{"hooks": [{
|
|
"type": "command",
|
|
"command": "user",
|
|
"timeout": 0,
|
|
}]}]}},
|
|
None,
|
|
None,
|
|
None,
|
|
"positive",
|
|
),
|
|
(None, "{ malformed", None, None, "legacy hooks"),
|
|
(None, None, {"hooks": None}, None, "JSON object"),
|
|
(None, None, None, {"hooksDisabled": "yes"}, "boolean"),
|
|
],
|
|
)
|
|
def test_install_preserves_malformed_sources_before_writes(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
root_config: Any,
|
|
legacy_config: Any,
|
|
settings: Any,
|
|
local_settings: Any,
|
|
pattern: str,
|
|
) -> None:
|
|
fixture = DroidFixture(
|
|
monkeypatch,
|
|
tmp_path,
|
|
root_config=root_config,
|
|
legacy_config=legacy_config,
|
|
settings=settings,
|
|
local_settings=local_settings,
|
|
)
|
|
originals = {
|
|
path: path.read_text(encoding="utf-8")
|
|
for path in (
|
|
fixture.root_path,
|
|
fixture.legacy_path,
|
|
fixture.settings_path,
|
|
fixture.local_settings_path,
|
|
)
|
|
if path.exists()
|
|
}
|
|
with pytest.raises((ValueError, OSError), match=pattern):
|
|
fixture.install()
|
|
for path, source in originals.items():
|
|
assert path.read_text(encoding="utf-8") == source
|
|
assert not fixture.runtime_config.exists()
|
|
assert not fixture.root_path.exists() or fixture.root_path in originals
|
|
|
|
|
|
def test_unknown_events_and_extension_fields_remain_valid(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
fixture = DroidFixture(
|
|
monkeypatch,
|
|
tmp_path,
|
|
root_config={
|
|
"hooks": {
|
|
"FutureFactoryEvent": [{
|
|
"matcher": ".*",
|
|
"commandRegex": "^factory",
|
|
"owner": "user",
|
|
"hooks": [{
|
|
"type": "command",
|
|
"command": "user-command",
|
|
"timeout": 1,
|
|
"future": True,
|
|
}],
|
|
}],
|
|
},
|
|
},
|
|
)
|
|
fixture.install()
|
|
current = load_jsonc(fixture.root_path)
|
|
assert current["hooks"]["FutureFactoryEvent"][0]["owner"] == "user"
|
|
|
|
|
|
def test_uninstall_preserves_users_and_exact_ownership(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
fixture = DroidFixture(
|
|
monkeypatch,
|
|
tmp_path,
|
|
root_config={"hooks": {"Notification": []}},
|
|
)
|
|
fixture.install()
|
|
root = load_jsonc(fixture.root_path)
|
|
group = managed_group(root["hooks"], "PreToolUse", "guard.py")
|
|
assert group is not None
|
|
command = group["hooks"][0]["command"]
|
|
group["hooks"].append({"type": "command", "command": "user-command"})
|
|
root["hooks"]["PreToolUse"].extend([
|
|
{
|
|
"matcher": "*",
|
|
"hooks": [{
|
|
"type": "command",
|
|
"command": command.replace("guard.py", "guard.py.backup"),
|
|
"timeout": 10,
|
|
}],
|
|
},
|
|
{
|
|
"matcher": "*",
|
|
"hooks": [{
|
|
"type": "command",
|
|
"command": command.replace("agent-1", "agent-10"),
|
|
"timeout": 10,
|
|
}],
|
|
},
|
|
])
|
|
write_json(fixture.root_path, root)
|
|
fixture.plugin.uninstall("AGENT-1" if os.name == "nt" else AGENT_ID)
|
|
remaining = load_jsonc(fixture.root_path)
|
|
raw = json.dumps(remaining)
|
|
assert "user-command" in raw
|
|
assert "guard.py.backup" in raw
|
|
assert "agent-10" in raw
|
|
assert "PostToolUse" not in remaining["hooks"]
|
|
|
|
|
|
def test_uninstall_removes_owned_empty_file_and_keeps_absence(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
fixture = DroidFixture(monkeypatch, tmp_path / "installed")
|
|
fixture.install()
|
|
fixture.plugin.uninstall(AGENT_ID)
|
|
assert not fixture.root_path.exists()
|
|
|
|
empty = DroidFixture(monkeypatch, tmp_path / "empty")
|
|
empty.plugin.uninstall(AGENT_ID)
|
|
assert not empty.root_path.exists()
|
|
|
|
|
|
@pytest.mark.parametrize("kind", ["factory", "hook", "runtime"])
|
|
def test_install_rejects_linked_paths_before_writes(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
kind: str,
|
|
) -> None:
|
|
fixture = DroidFixture(monkeypatch, tmp_path)
|
|
target = tmp_path / f"{kind}-target"
|
|
target.mkdir()
|
|
try:
|
|
if kind == "factory":
|
|
fixture.home_dir.mkdir(parents=True)
|
|
fixture.factory_dir.symlink_to(target, target_is_directory=True)
|
|
elif kind == "hook":
|
|
fixture.factory_dir.mkdir(parents=True)
|
|
target_file = target / "hooks.json"
|
|
write_json(target_file, {"hooks": {}})
|
|
fixture.root_path.symlink_to(target_file)
|
|
else:
|
|
fixture.home_dir.mkdir(parents=True)
|
|
(fixture.home_dir / ".elydora").symlink_to(
|
|
target,
|
|
target_is_directory=True,
|
|
)
|
|
except OSError as error:
|
|
pytest.skip(f"Symbolic links unavailable: {error}")
|
|
with pytest.raises(OSError, match="physical (directory|file)"):
|
|
fixture.install()
|