254 lines
7.9 KiB
Python
254 lines
7.9 KiB
Python
from __future__ import annotations
|
|
|
|
import base64
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import subprocess # nosec B404
|
|
import threading
|
|
from typing import Any, Dict, Iterable, Optional
|
|
|
|
import pytest
|
|
|
|
from elydora.plugins import _jsonc, droid, droid_policy
|
|
from elydora.plugins.base import InstallConfig
|
|
|
|
|
|
AGENT_ID = "agent-1"
|
|
VALID_PRIVATE_KEY = base64.urlsafe_b64encode(bytes([13]) * 32).rstrip(b"=").decode()
|
|
JsonObject = Dict[str, Any]
|
|
|
|
|
|
class DroidFixture:
|
|
def __init__(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
*,
|
|
root_config: Any = None,
|
|
legacy_config: Any = None,
|
|
settings: Any = None,
|
|
local_settings: Any = None,
|
|
project_settings: Any = None,
|
|
project_local_settings: Any = None,
|
|
base_url: str = "http://127.0.0.1:9",
|
|
agent_id: str = AGENT_ID,
|
|
) -> None:
|
|
self.root_dir = tmp_path
|
|
self.home_dir = tmp_path / "home with spaces and 'quote %DROID%"
|
|
self.workspace_dir = tmp_path / "workspace with spaces"
|
|
self.factory_dir = self.home_dir / ".factory"
|
|
self.root_path = self.factory_dir / "hooks.json"
|
|
self.legacy_path = self.factory_dir / "hooks" / "hooks.json"
|
|
self.settings_path = self.factory_dir / "settings.json"
|
|
self.local_settings_path = self.factory_dir / "settings.local.json"
|
|
self.project_factory_dir = self.workspace_dir / ".factory"
|
|
self.project_settings_path = self.project_factory_dir / "settings.json"
|
|
self.project_local_settings_path = (
|
|
self.project_factory_dir / "settings.local.json"
|
|
)
|
|
self.system_settings_path = (
|
|
tmp_path / "managed factory" / "settings.json"
|
|
)
|
|
self.agent_id = agent_id
|
|
self.agent_dir = self.home_dir / ".elydora" / agent_id
|
|
self.guard_path = self.agent_dir / "guard.py"
|
|
self.audit_path = self.agent_dir / "hook.py"
|
|
self.runtime_config = self.agent_dir / "config.json"
|
|
self.private_key_path = self.agent_dir / "private.key"
|
|
self.workspace_dir.mkdir(parents=True)
|
|
(self.workspace_dir / ".git").mkdir()
|
|
self._write(self.root_path, root_config)
|
|
self._write(self.legacy_path, legacy_config)
|
|
self._write(self.settings_path, settings)
|
|
self._write(self.local_settings_path, local_settings)
|
|
self._write(self.project_settings_path, project_settings)
|
|
self._write(self.project_local_settings_path, project_local_settings)
|
|
monkeypatch.setenv("HOME", str(self.home_dir))
|
|
monkeypatch.setenv("USERPROFILE", str(self.home_dir))
|
|
monkeypatch.chdir(self.workspace_dir)
|
|
monkeypatch.setattr(
|
|
droid_policy,
|
|
"_managed_settings_path",
|
|
lambda: str(self.system_settings_path),
|
|
)
|
|
self.config: InstallConfig = {
|
|
"agent_id": agent_id,
|
|
"agent_name": "droid",
|
|
"org_id": "org-1",
|
|
"private_key": VALID_PRIVATE_KEY,
|
|
"kid": "kid-1",
|
|
"token": "token-1",
|
|
"base_url": base_url,
|
|
"guard_script_path": str(self.guard_path),
|
|
}
|
|
self.plugin = droid.DroidPlugin()
|
|
|
|
@staticmethod
|
|
def _write(path: Path, value: Any) -> None:
|
|
if value is None:
|
|
return
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
raw = value if isinstance(value, str) else json.dumps(value, indent=2) + "\n"
|
|
with open(path, "w", encoding="utf-8", newline="") as file:
|
|
file.write(raw)
|
|
|
|
def install(self, **overrides: str) -> None:
|
|
config = InstallConfig(**self.config)
|
|
config.update(overrides)
|
|
self.plugin.install(config)
|
|
|
|
def write_system_settings(self, value: Any) -> None:
|
|
self._write(self.system_settings_path, value)
|
|
|
|
|
|
def load_jsonc(path: Path) -> JsonObject:
|
|
value = _jsonc.parse_jsonc(path.read_text(encoding="utf-8"), str(path))
|
|
assert isinstance(value, dict)
|
|
return value
|
|
|
|
|
|
def write_json(path: Path, value: Any) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
source = value if isinstance(value, str) else json.dumps(value, indent=2) + "\n"
|
|
with open(path, "w", encoding="utf-8", newline="") as file:
|
|
file.write(source)
|
|
|
|
|
|
def read_raw(path: Path) -> str:
|
|
with open(path, "r", encoding="utf-8", newline="") as file:
|
|
return file.read()
|
|
|
|
|
|
def managed_group(
|
|
hooks: JsonObject,
|
|
event: str,
|
|
script_name: str,
|
|
) -> Optional[JsonObject]:
|
|
for group in hooks.get(event, []):
|
|
if any(
|
|
script_name in handler.get("command", "")
|
|
for handler in group.get("hooks", [])
|
|
):
|
|
return group
|
|
return None
|
|
|
|
|
|
def managed_handler(
|
|
hooks: JsonObject,
|
|
event: str,
|
|
script_name: str,
|
|
) -> Optional[JsonObject]:
|
|
group = managed_group(hooks, event, script_name)
|
|
if group is None:
|
|
return None
|
|
return next(
|
|
handler
|
|
for handler in group["hooks"]
|
|
if script_name in handler.get("command", "")
|
|
)
|
|
|
|
|
|
def assert_native_group(group: Optional[JsonObject]) -> None:
|
|
assert group is not None
|
|
assert sorted(group) == ["hooks", "matcher"]
|
|
assert group["matcher"] == "*"
|
|
assert len(group["hooks"]) == 1
|
|
handler = group["hooks"][0]
|
|
assert sorted(handler) == ["command", "timeout", "type"]
|
|
assert handler["type"] == "command"
|
|
assert handler["timeout"] == 10
|
|
|
|
|
|
def run_hook(
|
|
command: str,
|
|
payload: str,
|
|
) -> subprocess.CompletedProcess[str]:
|
|
argv = (
|
|
[
|
|
"powershell.exe",
|
|
"-NoLogo",
|
|
"-NoProfile",
|
|
"-NonInteractive",
|
|
"-Command",
|
|
command,
|
|
]
|
|
if os.name == "nt"
|
|
else ["/bin/sh", "-c", command]
|
|
)
|
|
return subprocess.run( # nosec B603
|
|
argv,
|
|
input=payload,
|
|
text=True,
|
|
capture_output=True,
|
|
check=False,
|
|
timeout=10,
|
|
)
|
|
|
|
|
|
def assert_no_transaction_files(root: Path) -> None:
|
|
leaked = [
|
|
path
|
|
for path in root.rglob("*")
|
|
if path.suffix in {".tmp", ".rollback"}
|
|
]
|
|
assert leaked == []
|
|
|
|
|
|
def snapshot(paths: Iterable[Path]) -> Dict[Path, str]:
|
|
return {
|
|
path: read_raw(path)
|
|
for path in paths
|
|
}
|
|
|
|
|
|
def assert_snapshot(expected: Dict[Path, str]) -> None:
|
|
for path, source in expected.items():
|
|
assert read_raw(path) == source
|
|
|
|
|
|
class _ApiHandler(BaseHTTPRequestHandler):
|
|
def _respond(self, status: int, payload: str) -> None:
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.end_headers()
|
|
self.wfile.write(payload.encode("utf-8"))
|
|
|
|
def do_GET(self) -> None:
|
|
self.server.requests.append(("GET", self.path, b"")) # type: ignore[attr-defined]
|
|
self._respond(200, '{"agent":{"status":"active"}}')
|
|
|
|
def do_POST(self) -> None:
|
|
length = int(self.headers.get("Content-Length", "0"))
|
|
body = self.rfile.read(length)
|
|
self.server.requests.append( # type: ignore[attr-defined]
|
|
("POST", self.path, body)
|
|
)
|
|
self._respond(201, '{"operation":{"accepted":true}}')
|
|
|
|
def log_message(self, _format: str, *args: object) -> None:
|
|
del args
|
|
|
|
|
|
class ApiServer:
|
|
def __init__(self) -> None:
|
|
self.server = ThreadingHTTPServer(("127.0.0.1", 0), _ApiHandler)
|
|
self.server.requests = [] # type: ignore[attr-defined]
|
|
self.thread = threading.Thread(
|
|
target=self.server.serve_forever,
|
|
daemon=True,
|
|
)
|
|
self.thread.start()
|
|
host, port = self.server.server_address
|
|
self.base_url = f"http://{host}:{port}"
|
|
|
|
@property
|
|
def requests(self) -> list[tuple[str, str, bytes]]:
|
|
return self.server.requests # type: ignore[attr-defined,no-any-return]
|
|
|
|
def close(self) -> None:
|
|
self.server.shutdown()
|
|
self.thread.join(timeout=5)
|
|
self.server.server_close()
|