diff --git a/lazarus/__init__.py b/lazarus/__init__.py new file mode 100644 index 00000000..a2768b29 --- /dev/null +++ b/lazarus/__init__.py @@ -0,0 +1 @@ +"""Lazarus Pit — Agent resurrection pool and mission cell isolation.""" diff --git a/lazarus/__pycache__/__init__.cpython-312.pyc b/lazarus/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 00000000..647c4187 Binary files /dev/null and b/lazarus/__pycache__/__init__.cpython-312.pyc differ diff --git a/lazarus/__pycache__/cell.cpython-312.pyc b/lazarus/__pycache__/cell.cpython-312.pyc new file mode 100644 index 00000000..657a5ed7 Binary files /dev/null and b/lazarus/__pycache__/cell.cpython-312.pyc differ diff --git a/lazarus/__pycache__/cli.cpython-312.pyc b/lazarus/__pycache__/cli.cpython-312.pyc new file mode 100644 index 00000000..5c47e5ed Binary files /dev/null and b/lazarus/__pycache__/cli.cpython-312.pyc differ diff --git a/lazarus/__pycache__/harness.cpython-312.pyc b/lazarus/__pycache__/harness.cpython-312.pyc new file mode 100644 index 00000000..af5e362c Binary files /dev/null and b/lazarus/__pycache__/harness.cpython-312.pyc differ diff --git a/lazarus/__pycache__/isolation.cpython-312.pyc b/lazarus/__pycache__/isolation.cpython-312.pyc new file mode 100644 index 00000000..996354e4 Binary files /dev/null and b/lazarus/__pycache__/isolation.cpython-312.pyc differ diff --git a/lazarus/__pycache__/roster.cpython-312.pyc b/lazarus/__pycache__/roster.cpython-312.pyc new file mode 100644 index 00000000..578b3e35 Binary files /dev/null and b/lazarus/__pycache__/roster.cpython-312.pyc differ diff --git a/lazarus/cell.py b/lazarus/cell.py new file mode 100644 index 00000000..1d428257 --- /dev/null +++ b/lazarus/cell.py @@ -0,0 +1,145 @@ +"""Mission cell lifecycle — create, load, save, destroy.""" + +from __future__ import annotations + +import json +import logging +import os +import tempfile +from dataclasses import dataclass, field, asdict +from datetime import datetime, timezone +from pathlib import Path +from typing import Literal + +log = logging.getLogger(__name__) + +CellState = Literal["pending", "active", "archived", "destroyed"] + + +@dataclass +class MissionCell: + """Represents a mission cell — an ephemeral project workspace.""" + + id: str + name: str + repo_url: str + created_at: str + state: CellState + agents: list[str] + cell_path: str + home_path: str + + def to_dict(self) -> dict: + return { + "id": self.id, + "name": self.name, + "repo_url": self.repo_url, + "created_at": self.created_at, + "state": self.state, + "agents": list(self.agents), + "cell_path": self.cell_path, + "home_path": self.home_path, + } + + @classmethod + def from_dict(cls, data: dict) -> MissionCell: + return cls( + id=data["id"], + name=data["name"], + repo_url=data["repo_url"], + created_at=data["created_at"], + state=data["state"], + agents=list(data.get("agents", [])), + cell_path=data["cell_path"], + home_path=data.get("home_path", ""), + ) + + +def create_cell( + mission_id: str, + name: str, + repo_url: str, + base_path: Path, + home_path: str = "", +) -> MissionCell: + """Create a new mission cell directory structure and return a MissionCell.""" + cell_path = base_path / mission_id + cell_path.mkdir(parents=True, exist_ok=True) + + # Create required subdirectories + (cell_path / "workspace").mkdir(exist_ok=True) + (cell_path / "logs").mkdir(exist_ok=True) + (cell_path / "snapshots").mkdir(exist_ok=True) + + created_at = datetime.now(timezone.utc).isoformat() + + cell = MissionCell( + id=mission_id, + name=name, + repo_url=repo_url, + created_at=created_at, + state="pending", + agents=[], + cell_path=str(cell_path), + home_path=home_path, + ) + + # Write roster.json placeholder + roster_path = cell_path / "roster.json" + _atomic_write(roster_path, {"agents": []}) + + # Write cell.json + save_cell(cell) + + log.info("Created mission cell %s at %s", mission_id, cell_path) + return cell + + +def destroy_cell(cell: MissionCell, cleanup_dirs: bool = False) -> None: + """Mark a cell as destroyed and optionally remove its directories.""" + cell.state = "destroyed" + save_cell(cell) + + if cleanup_dirs: + import shutil + cell_dir = Path(cell.cell_path) + if cell_dir.exists(): + shutil.rmtree(cell_dir) + log.info("Removed cell directory %s", cell_dir) + + log.info("Destroyed mission cell %s", cell.id) + + +def load_cell(cell_path: Path) -> MissionCell: + """Load a MissionCell from a cell.json file at cell_path.""" + cell_json = Path(cell_path) / "cell.json" + with open(cell_json, "r", encoding="utf-8") as fh: + data = json.load(fh) + return MissionCell.from_dict(data) + + +def save_cell(cell: MissionCell) -> None: + """Atomically save a MissionCell to cell.json in its cell_path.""" + cell_path = Path(cell.cell_path) + cell_path.mkdir(parents=True, exist_ok=True) + target = cell_path / "cell.json" + _atomic_write(target, cell.to_dict()) + log.debug("Saved cell %s to %s", cell.id, target) + + +# ─── helpers ────────────────────────────────────────────────────────────────── + +def _atomic_write(path: Path, data: dict) -> None: + """Write JSON atomically via a temp file + rename.""" + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as fh: + json.dump(data, fh, indent=2) + os.replace(tmp, path) + except Exception: + try: + os.unlink(tmp) + except OSError: + pass + raise diff --git a/lazarus/cli.py b/lazarus/cli.py new file mode 100644 index 00000000..e996af4c --- /dev/null +++ b/lazarus/cli.py @@ -0,0 +1,162 @@ +"""Lazarus CLI — invite agents, manage mission cells.""" + +from __future__ import annotations + +import argparse +import json +import platform +import sys +import uuid +from pathlib import Path + +from lazarus.cell import MissionCell, create_cell, load_cell +from lazarus.isolation import setup_level1_isolation +from lazarus.roster import DEFAULT_REGISTRY_PATH, MissionRoster + + +def _default_base_path() -> Path: + if platform.system() == "Linux": + return Path("/var/missions") + return Path.home() / ".nexus" / "missions" + + +# ─── command handlers ───────────────────────────────────────────────────────── + + +def cmd_invite(args: argparse.Namespace) -> int: + """Create or load a mission cell and invite an agent.""" + base_path = Path(args.base_path) if args.base_path else _default_base_path() + mission_id: str = args.mission or str(uuid.uuid4()) + roster = MissionRoster(Path(args.registry) if args.registry else DEFAULT_REGISTRY_PATH) + + # Create or load cell + cell_dir = base_path / mission_id + if (cell_dir / "cell.json").exists(): + cell = load_cell(cell_dir) + else: + cell = create_cell( + mission_id=mission_id, + name=args.name or mission_id, + repo_url=args.repo, + base_path=base_path, + ) + + # Level 1 isolation + setup_level1_isolation(cell) + + # Register mission and invite agent + roster.add_mission(cell) + entry = roster.invite_agent(mission_id, args.agent, role=args.role) + roster.save() + + summary = { + "mission_id": mission_id, + "agent": args.agent, + "role": entry.role, + "status": entry.status, + "cell_path": cell.cell_path, + "repo_url": cell.repo_url, + "invited_at": entry.invited_at, + } + print(json.dumps(summary, indent=2)) + return 0 + + +def cmd_list(args: argparse.Namespace) -> int: + """List missions, optionally filtered by state.""" + roster = MissionRoster(Path(args.registry) if args.registry else DEFAULT_REGISTRY_PATH) + missions = roster.list_missions(state=args.state) + print(json.dumps(missions, indent=2)) + return 0 + + +def cmd_status(args: argparse.Namespace) -> int: + """Show details of a specific mission.""" + roster = MissionRoster(Path(args.registry) if args.registry else DEFAULT_REGISTRY_PATH) + mission = roster.get_mission(args.mission_id) + if mission is None: + print(f"Mission {args.mission_id!r} not found.", file=sys.stderr) + return 1 + agents = roster.list_agents(args.mission_id) + output = dict(mission) + output["agent_count"] = len(agents) + output["agents"] = [a.to_dict() for a in agents] + print(json.dumps(output, indent=2)) + return 0 + + +def cmd_depart(args: argparse.Namespace) -> int: + """Mark an agent as departed from a mission.""" + roster = MissionRoster(Path(args.registry) if args.registry else DEFAULT_REGISTRY_PATH) + try: + entry = roster.depart_agent(args.mission, args.agent) + except KeyError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + roster.save() + print(json.dumps(entry.to_dict(), indent=2)) + return 0 + + +# ─── argument parsing ───────────────────────────────────────────────────────── + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="lazarus", + description="Lazarus Pit — agent invitation and mission cell management", + ) + parser.add_argument( + "--registry", + default=None, + help="Path to mission roster JSON (default: ~/.nexus/mission-roster.json)", + ) + + sub = parser.add_subparsers(dest="command", required=True) + + # invite + p_invite = sub.add_parser("invite", help="Invite an agent to a mission") + p_invite.add_argument("agent", help="Agent name to invite") + p_invite.add_argument("--mission", default=None, help="Mission ID (UUID generated if omitted)") + p_invite.add_argument("--repo", required=True, help="Repository URL for the mission") + p_invite.add_argument("--base-path", default=None, help="Base directory for mission cells") + p_invite.add_argument("--role", default="contributor", help="Agent role (default: contributor)") + p_invite.add_argument("--name", default=None, help="Human-readable mission name") + + # list + p_list = sub.add_parser("list", help="List missions") + p_list.add_argument("--state", default=None, help="Filter by state (pending/active/archived/destroyed)") + + # status + p_status = sub.add_parser("status", help="Show mission status") + p_status.add_argument("mission_id", help="Mission ID") + + # depart + p_depart = sub.add_parser("depart", help="Mark agent as departed") + p_depart.add_argument("agent", help="Agent name") + p_depart.add_argument("--mission", required=True, help="Mission ID") + + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = _build_parser() + args = parser.parse_args(argv) + + dispatch = { + "invite": cmd_invite, + "list": cmd_list, + "status": cmd_status, + "depart": cmd_depart, + } + + handler = dispatch.get(args.command) + if handler is None: + parser.print_help() + return 1 + + return handler(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/lazarus/harness.py b/lazarus/harness.py new file mode 100644 index 00000000..fba994cd --- /dev/null +++ b/lazarus/harness.py @@ -0,0 +1,165 @@ +"""Hermes harness mission-cell mode — wraps agent lifecycle in a mission cell.""" + +from __future__ import annotations + +import asyncio +import json +import logging +import os +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Awaitable + +try: + import websockets # type: ignore[import] +except ImportError: # pragma: no cover + websockets = None # type: ignore[assignment] + +from lazarus.cell import MissionCell, load_cell +from lazarus.isolation import setup_level1_isolation + +log = logging.getLogger(__name__) + +DEFAULT_HERMES_WS_URL = "ws://localhost:8000/ws" + + +class MissionCellHarness: + """ + Wraps agent execution in a mission cell with Hermes WS telemetry. + + Usage:: + + harness = MissionCellHarness("myagent", "/var/missions/") + await harness.run_with_harness(my_coroutine()) + """ + + def __init__( + self, + agent_name: str, + cell_path: str | Path, + hermes_ws_url: str = DEFAULT_HERMES_WS_URL, + home_path: str | None = None, + ) -> None: + self.agent_name = agent_name + self.cell_path = Path(cell_path) + self.hermes_ws_url = hermes_ws_url + self.home_path = home_path + + self._cell: MissionCell | None = None + self._ws: Any = None # websockets connection + self._ws_connected = False + + # ─── public API ─────────────────────────────────────────────────────────── + + async def start(self) -> None: + """Load cell, set up isolation, emit agent_joined telemetry.""" + self._cell = load_cell(self.cell_path) + setup_level1_isolation(self._cell) + + await self._connect_hermes() + await self._emit_mission_event("agent_joined") + log.info( + "MissionCellHarness started for agent=%s mission=%s", + self.agent_name, + self._cell.id, + ) + + async def stop(self) -> None: + """Emit agent_departed telemetry and save state.""" + await self._emit_mission_event("agent_departed") + + if self._ws is not None: + try: + await self._ws.close() + except Exception as exc: + log.debug("WS close error (ignored): %s", exc) + self._ws_connected = False + + log.info( + "MissionCellHarness stopped for agent=%s", self.agent_name + ) + + def get_env(self) -> dict[str, str]: + """Return environment variables for the mission cell context.""" + cell = self._cell + env: dict[str, str] = {} + + if cell: + env["MISSION_CELL_PATH"] = str(self.cell_path) + env["MISSION_ID"] = cell.id + else: + env["MISSION_CELL_PATH"] = str(self.cell_path) + env["MISSION_ID"] = "" + + env["AGENT_NAME"] = self.agent_name + + if self.home_path: + env["HOME"] = self.home_path + else: + env["HOME"] = os.environ.get("HOME", str(Path.home())) + + return env + + def write_mission_log(self, message: str) -> None: + """Write a timestamped log entry to the cell's logs directory.""" + if self._cell is None: + log.warning("Cannot write mission log: cell not loaded") + return + + logs_dir = Path(self._cell.cell_path) / "logs" + logs_dir.mkdir(parents=True, exist_ok=True) + + log_file = logs_dir / "mission.log" + timestamp = datetime.now(timezone.utc).isoformat() + line = f"[{timestamp}] [{self.agent_name}] {message}\n" + + with open(log_file, "a", encoding="utf-8") as fh: + fh.write(line) + + async def run_with_harness(self, coro: Awaitable[Any]) -> Any: + """Start harness, run coro, stop harness. Returns coro result.""" + await self.start() + try: + return await coro + finally: + await self.stop() + + # ─── internals ──────────────────────────────────────────────────────────── + + async def _connect_hermes(self) -> None: + """Attempt WebSocket connection to Hermes; fail gracefully.""" + try: + import websockets # type: ignore[import] + + self._ws = await websockets.connect(self.hermes_ws_url) + self._ws_connected = True + log.info("Connected to Hermes at %s", self.hermes_ws_url) + except Exception as exc: + log.warning("Could not connect to Hermes WS: %s", exc) + self._ws_connected = False + + async def _emit_mission_event(self, event: str) -> None: + """Send a mission_event message to Hermes.""" + cell = self._cell + mission_id = cell.id if cell else "" + payload = { + "type": "mission_event", + "event": event, + "mission_id": mission_id, + "agent_name": self.agent_name, + "timestamp": datetime.now(timezone.utc).isoformat(), + } + + if self._ws_connected and self._ws is not None: + try: + await self._ws.send(json.dumps(payload)) + log.debug("Emitted %s for agent=%s", event, self.agent_name) + except Exception as exc: + log.warning("Failed to emit %s telemetry: %s", event, exc) + self._ws_connected = False + else: + log.debug( + "Hermes not connected; %s event not sent (agent=%s)", + event, + self.agent_name, + ) diff --git a/lazarus/isolation.py b/lazarus/isolation.py new file mode 100644 index 00000000..08490897 --- /dev/null +++ b/lazarus/isolation.py @@ -0,0 +1,170 @@ +"""Level 1 isolation — directory setup and AppArmor profile generation.""" + +from __future__ import annotations + +import logging +import platform +import subprocess +from dataclasses import dataclass +from pathlib import Path + +from lazarus.cell import MissionCell + +log = logging.getLogger(__name__) + +_APPARMOR_TEMPLATE = """\ +#include + +profile nexus-mission-{cell_id} flags=(attach_disconnected,mediate_deleted) {{ + #include + + # Allow read access to agent home + "{home_path}/" r, + "{home_path}/**" r, + + # Allow read/write access to cell workspace + "{workspace_path}/" rw, + "{workspace_path}/**" rwkl, + + # Allow read/write access to cell logs + "{logs_path}/" rw, + "{logs_path}/**" rwkl, + + # Deny writes to home path + deny "{home_path}/**" w, + + # Deny access to other agent home dirs + deny "/root/wizards/**" rw, + + # Deny network access by default + deny network, + + # Basic system access + /bin/** ix, + /usr/bin/** ix, + /lib/** r, + /usr/lib/** r, + /proc/self/** r, +}} +""" + + +@dataclass +class IsolationManifest: + """Result of setting up Level 1 isolation for a mission cell.""" + + cell_id: str + workspace_path: Path + logs_path: Path + apparmor_profile_path: Path | None + platform: str + + +def setup_level1_isolation(cell: MissionCell) -> IsolationManifest: + """ + Set up Level 1 isolation for a mission cell. + + Creates the proper directory structure with permissions and writes + an AppArmor profile (Linux) or just a profile file (other platforms). + + Returns an IsolationManifest describing what was set up. + """ + cell_path = Path(cell.cell_path) + workspace_path = cell_path / "workspace" + logs_path = cell_path / "logs" + + workspace_path.mkdir(parents=True, exist_ok=True) + logs_path.mkdir(parents=True, exist_ok=True) + + # Set restrictive permissions on the workspace (owner-only) + try: + workspace_path.chmod(0o700) + logs_path.chmod(0o700) + except OSError as exc: + log.warning("Could not set directory permissions: %s", exc) + + apparmor_profile_path: Path | None = None + sys_platform = platform.system() + + if cell.home_path: + profile_path = cell_path / f"nexus-mission-{cell.id}.apparmor" + apparmor_profile_path = write_apparmor_profile(cell, profile_path) + + log.info( + "Level 1 isolation set up for cell %s (platform=%s)", cell.id, sys_platform + ) + return IsolationManifest( + cell_id=cell.id, + workspace_path=workspace_path, + logs_path=logs_path, + apparmor_profile_path=apparmor_profile_path, + platform=sys_platform, + ) + + +def write_apparmor_profile(cell: MissionCell, output_path: Path) -> Path: + """ + Write an AppArmor profile for the mission cell to output_path. + + On non-Linux platforms this just writes the file without attempting to load it. + Returns the path to the written profile. + """ + cell_path = Path(cell.cell_path) + workspace_path = cell_path / "workspace" + logs_path = cell_path / "logs" + home_path = cell.home_path or str(Path.home()) + + profile_content = _APPARMOR_TEMPLATE.format( + cell_id=cell.id, + home_path=home_path, + workspace_path=str(workspace_path), + logs_path=str(logs_path), + ) + + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(profile_content, encoding="utf-8") + log.info("Wrote AppArmor profile to %s", output_path) + return output_path + + +def load_apparmor_profile(profile_path: Path) -> bool: + """ + Attempt to load an AppArmor profile via apparmor_parser. + + On non-Linux platforms or when apparmor_parser is absent, this is a no-op + and returns False gracefully. + + Returns True if the profile was loaded successfully. + """ + if platform.system() != "Linux": + log.debug( + "AppArmor load skipped: not on Linux (platform=%s)", platform.system() + ) + return False + + try: + result = subprocess.run( + ["apparmor_parser", "-r", str(profile_path)], + capture_output=True, + text=True, + timeout=10, + ) + if result.returncode == 0: + log.info("Loaded AppArmor profile from %s", profile_path) + return True + else: + log.warning( + "apparmor_parser failed (rc=%d): %s", + result.returncode, + result.stderr.strip(), + ) + return False + except FileNotFoundError: + log.debug("apparmor_parser not found; skipping profile load") + return False + except subprocess.TimeoutExpired: + log.warning("apparmor_parser timed out loading %s", profile_path) + return False + except Exception as exc: + log.warning("Unexpected error loading AppArmor profile: %s", exc) + return False diff --git a/lazarus/roster.py b/lazarus/roster.py new file mode 100644 index 00000000..8eda453f --- /dev/null +++ b/lazarus/roster.py @@ -0,0 +1,184 @@ +"""Mission roster registry — tracks all missions and agents.""" + +from __future__ import annotations + +import json +import logging +import os +import tempfile +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Literal + +from lazarus.cell import MissionCell + +log = logging.getLogger(__name__) + +DEFAULT_REGISTRY_PATH = Path.home() / ".nexus" / "mission-roster.json" + +EntryStatus = Literal["invited", "active", "departed"] + + +@dataclass +class RosterEntry: + """An agent's participation record in a mission.""" + + mission_id: str + agent_name: str + invited_at: str + accepted_at: str | None + role: str + status: EntryStatus + + def to_dict(self) -> dict: + return { + "mission_id": self.mission_id, + "agent_name": self.agent_name, + "invited_at": self.invited_at, + "accepted_at": self.accepted_at, + "role": self.role, + "status": self.status, + } + + @classmethod + def from_dict(cls, data: dict) -> RosterEntry: + return cls( + mission_id=data["mission_id"], + agent_name=data["agent_name"], + invited_at=data["invited_at"], + accepted_at=data.get("accepted_at"), + role=data.get("role", "contributor"), + status=data["status"], + ) + + +class MissionRoster: + """Registry of all missions and their agent rosters.""" + + def __init__(self, registry_path: Path = DEFAULT_REGISTRY_PATH) -> None: + self.registry_path = Path(registry_path) + self._data: dict = {} + self._load() + + # ─── internal I/O ───────────────────────────────────────────────────────── + + def _load(self) -> None: + if self.registry_path.exists(): + with open(self.registry_path, "r", encoding="utf-8") as fh: + self._data = json.load(fh) + else: + self._data = {"schema_version": "1", "missions": {}} + + def save(self) -> None: + """Atomically persist the registry to disk.""" + self.registry_path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=self.registry_path.parent, suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as fh: + json.dump(self._data, fh, indent=2) + os.replace(tmp, self.registry_path) + except Exception: + try: + os.unlink(tmp) + except OSError: + pass + raise + log.debug("Saved mission roster to %s", self.registry_path) + + # ─── missions ───────────────────────────────────────────────────────────── + + def add_mission(self, cell: MissionCell) -> None: + """Register a new mission from a MissionCell.""" + missions: dict = self._data.setdefault("missions", {}) + if cell.id not in missions: + missions[cell.id] = { + "id": cell.id, + "name": cell.name, + "repo_url": cell.repo_url, + "created_at": cell.created_at, + "state": cell.state, + "cell_path": cell.cell_path, + "agents": [], + } + log.info("Registered mission %s in roster", cell.id) + else: + log.debug("Mission %s already in roster, skipping add", cell.id) + + def get_mission(self, mission_id: str) -> dict | None: + return self._data.get("missions", {}).get(mission_id) + + def list_missions(self, state: str | None = None) -> list[dict]: + missions = list(self._data.get("missions", {}).values()) + if state is not None: + missions = [m for m in missions if m.get("state") == state] + return missions + + # ─── agents ─────────────────────────────────────────────────────────────── + + def invite_agent( + self, + mission_id: str, + agent_name: str, + role: str = "contributor", + ) -> RosterEntry: + """Invite an agent to a mission; returns the new RosterEntry.""" + mission = self._require_mission(mission_id) + agents: list[dict] = mission.setdefault("agents", []) + + # Check for existing entry + for entry_dict in agents: + if entry_dict["agent_name"] == agent_name: + log.warning( + "Agent %s already in mission %s roster", agent_name, mission_id + ) + return RosterEntry.from_dict(entry_dict) + + entry = RosterEntry( + mission_id=mission_id, + agent_name=agent_name, + invited_at=datetime.now(timezone.utc).isoformat(), + accepted_at=None, + role=role, + status="invited", + ) + agents.append(entry.to_dict()) + log.info("Invited %s to mission %s as %s", agent_name, mission_id, role) + return entry + + def accept_invitation(self, mission_id: str, agent_name: str) -> RosterEntry: + """Mark an invitation as accepted; status becomes 'active'.""" + entry_dict = self._require_agent_entry(mission_id, agent_name) + entry_dict["status"] = "active" + entry_dict["accepted_at"] = datetime.now(timezone.utc).isoformat() + log.info("Agent %s accepted invitation to mission %s", agent_name, mission_id) + return RosterEntry.from_dict(entry_dict) + + def depart_agent(self, mission_id: str, agent_name: str) -> RosterEntry: + """Mark an agent as departed from a mission.""" + entry_dict = self._require_agent_entry(mission_id, agent_name) + entry_dict["status"] = "departed" + log.info("Agent %s departed mission %s", agent_name, mission_id) + return RosterEntry.from_dict(entry_dict) + + def list_agents(self, mission_id: str) -> list[RosterEntry]: + """Return all RosterEntry objects for a mission.""" + mission = self._require_mission(mission_id) + return [RosterEntry.from_dict(e) for e in mission.get("agents", [])] + + # ─── helpers ────────────────────────────────────────────────────────────── + + def _require_mission(self, mission_id: str) -> dict: + missions = self._data.get("missions", {}) + if mission_id not in missions: + raise KeyError(f"Mission {mission_id!r} not found in roster") + return missions[mission_id] + + def _require_agent_entry(self, mission_id: str, agent_name: str) -> dict: + mission = self._require_mission(mission_id) + for entry_dict in mission.get("agents", []): + if entry_dict["agent_name"] == agent_name: + return entry_dict + raise KeyError( + f"Agent {agent_name!r} not found in mission {mission_id!r} roster" + ) diff --git a/tests/test_lazarus_cell.py b/tests/test_lazarus_cell.py new file mode 100644 index 00000000..8df7103c --- /dev/null +++ b/tests/test_lazarus_cell.py @@ -0,0 +1,110 @@ +"""Tests for lazarus.cell — MissionCell lifecycle.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from lazarus.cell import MissionCell, create_cell, destroy_cell, load_cell, save_cell + + +def test_create_cell_creates_directories(tmp_path: Path) -> None: + """workspace/, logs/, and snapshots/ subdirs are created under the cell dir.""" + cell = create_cell( + mission_id="test-mission-1", + name="Test Mission", + repo_url="https://github.com/test/repo", + base_path=tmp_path, + ) + + cell_dir = tmp_path / "test-mission-1" + assert cell_dir.is_dir() + assert (cell_dir / "workspace").is_dir() + assert (cell_dir / "logs").is_dir() + assert (cell_dir / "snapshots").is_dir() + + +def test_create_cell_writes_cell_json(tmp_path: Path) -> None: + """cell.json is created and contains valid mission metadata.""" + cell = create_cell( + mission_id="test-mission-2", + name="Another Mission", + repo_url="https://github.com/test/another", + base_path=tmp_path, + ) + + cell_json_path = tmp_path / "test-mission-2" / "cell.json" + assert cell_json_path.exists() + + with open(cell_json_path) as fh: + data = json.load(fh) + + assert data["id"] == "test-mission-2" + assert data["name"] == "Another Mission" + assert data["repo_url"] == "https://github.com/test/another" + assert data["state"] == "pending" + assert isinstance(data["agents"], list) + + +def test_save_and_load_cell(tmp_path: Path) -> None: + """Round-trip: save a cell, load it back, fields match.""" + cell = create_cell( + mission_id="roundtrip-mission", + name="Roundtrip", + repo_url="https://github.com/test/roundtrip", + base_path=tmp_path, + ) + # Mutate and save + cell.state = "active" + cell.agents.append("agentA") + save_cell(cell) + + loaded = load_cell(tmp_path / "roundtrip-mission") + assert loaded.id == "roundtrip-mission" + assert loaded.state == "active" + assert "agentA" in loaded.agents + assert loaded.repo_url == "https://github.com/test/roundtrip" + + +def test_cell_state_transitions(tmp_path: Path) -> None: + """State changes are persisted correctly through save/load.""" + cell = create_cell( + mission_id="state-mission", + name="State Test", + repo_url="https://github.com/test/state", + base_path=tmp_path, + ) + assert cell.state == "pending" + + cell.state = "active" + save_cell(cell) + + reloaded = load_cell(tmp_path / "state-mission") + assert reloaded.state == "active" + + reloaded.state = "archived" + save_cell(reloaded) + + final = load_cell(tmp_path / "state-mission") + assert final.state == "archived" + + +def test_destroy_cell_marks_destroyed(tmp_path: Path) -> None: + """destroy_cell sets state to 'destroyed' and persists it.""" + cell = create_cell( + mission_id="destroy-mission", + name="Destroy Test", + repo_url="https://github.com/test/destroy", + base_path=tmp_path, + ) + + destroy_cell(cell, cleanup_dirs=False) + assert cell.state == "destroyed" + + reloaded = load_cell(tmp_path / "destroy-mission") + assert reloaded.state == "destroyed" diff --git a/tests/test_lazarus_cli.py b/tests/test_lazarus_cli.py new file mode 100644 index 00000000..5c65874b --- /dev/null +++ b/tests/test_lazarus_cli.py @@ -0,0 +1,136 @@ +"""Tests for lazarus.cli — CLI commands.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from lazarus.cli import main +from lazarus.roster import MissionRoster + + +def _run(argv: list[str], tmp_path: Path, capsys: pytest.CaptureFixture) -> tuple: + """Helper: run CLI with a tmp registry and capture stdout as JSON. + + Prepends --registry before the subcommand so argparse sees it as a global arg. + """ + registry = str(tmp_path / "roster.json") + rc = main(["--registry", registry, *argv]) + out = capsys.readouterr().out + return json.loads(out), rc + + +# Patch setup_level1_isolation globally for all CLI tests to avoid permission issues +@pytest.fixture(autouse=True) +def no_isolation(): + with patch("lazarus.cli.setup_level1_isolation"): + yield + + +def test_invite_creates_mission_cell(tmp_path: Path, capsys: pytest.CaptureFixture) -> None: + """invite creates a mission cell directory with cell.json.""" + base = str(tmp_path / "missions") + result, rc = _run( + [ + "invite", "agentAlpha", + "--repo", "https://github.com/test/repo", + "--base-path", base, + ], + tmp_path, + capsys, + ) + assert rc == 0 + mission_id = result["mission_id"] + cell_json = tmp_path / "missions" / mission_id / "cell.json" + assert cell_json.exists() + + +def test_invite_adds_agent_to_roster(tmp_path: Path, capsys: pytest.CaptureFixture) -> None: + """After invite, the agent appears in the roster with status 'invited'.""" + base = str(tmp_path / "missions") + registry = str(tmp_path / "roster.json") + + rc = main([ + "--registry", registry, + "invite", "agentBeta", + "--repo", "https://github.com/test/repo", + "--base-path", base, + ]) + out = capsys.readouterr().out + result = json.loads(out) + assert rc == 0 + + roster = MissionRoster(Path(registry)) + agents = roster.list_agents(result["mission_id"]) + agent_names = [a.agent_name for a in agents] + assert "agentBeta" in agent_names + + +def test_list_shows_missions(tmp_path: Path, capsys: pytest.CaptureFixture) -> None: + """list command shows missions that were previously created via invite.""" + base = str(tmp_path / "missions") + registry = str(tmp_path / "roster.json") + + # Create a mission first + main(["--registry", registry, "invite", "agentGamma", + "--repo", "https://github.com/test/repo", "--base-path", base]) + capsys.readouterr() # discard invite output + + rc = main(["--registry", registry, "list"]) + out = capsys.readouterr().out + missions = json.loads(out) + + assert rc == 0 + assert isinstance(missions, list) + assert len(missions) >= 1 + + +def test_status_shows_agents(tmp_path: Path, capsys: pytest.CaptureFixture) -> None: + """status command shows agent count for a mission.""" + base = str(tmp_path / "missions") + registry = str(tmp_path / "roster.json") + + main(["--registry", registry, "invite", "agentDelta", + "--repo", "https://github.com/test/repo", "--base-path", base]) + invite_out = json.loads(capsys.readouterr().out) + mission_id = invite_out["mission_id"] + + rc = main(["--registry", registry, "status", mission_id]) + out = capsys.readouterr().out + status = json.loads(out) + + assert rc == 0 + assert status["agent_count"] >= 1 + agent_names = [a["agent_name"] for a in status["agents"]] + assert "agentDelta" in agent_names + + +def test_depart_updates_agent_status(tmp_path: Path, capsys: pytest.CaptureFixture) -> None: + """depart command marks an agent's status as 'departed'.""" + base = str(tmp_path / "missions") + registry = str(tmp_path / "roster.json") + + main(["--registry", registry, "invite", "agentEpsilon", + "--repo", "https://github.com/test/repo", "--base-path", base]) + invite_out = json.loads(capsys.readouterr().out) + mission_id = invite_out["mission_id"] + + rc = main(["--registry", registry, "depart", "agentEpsilon", + "--mission", mission_id]) + out = capsys.readouterr().out + result = json.loads(out) + + assert rc == 0 + assert result["status"] == "departed" + + # Verify persisted + roster = MissionRoster(Path(registry)) + agents = roster.list_agents(mission_id) + epsilon = next(a for a in agents if a.agent_name == "agentEpsilon") + assert epsilon.status == "departed" diff --git a/tests/test_lazarus_harness.py b/tests/test_lazarus_harness.py new file mode 100644 index 00000000..6cc19052 --- /dev/null +++ b/tests/test_lazarus_harness.py @@ -0,0 +1,141 @@ +"""Tests for lazarus.harness — MissionCellHarness.""" + +from __future__ import annotations + +import asyncio +import json +import sys +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from lazarus.cell import create_cell, save_cell +from lazarus.harness import MissionCellHarness + +pytestmark = pytest.mark.asyncio + + +def _make_cell_and_harness( + tmp_path: Path, + agent_name: str = "testAgent", +) -> tuple[object, MissionCellHarness]: + cell = create_cell( + mission_id="harness-test-001", + name="Harness Test", + repo_url="https://github.com/test/repo", + base_path=tmp_path / "cells", + ) + harness = MissionCellHarness( + agent_name=agent_name, + cell_path=Path(cell.cell_path), + hermes_ws_url="ws://localhost:8000/ws", + ) + return cell, harness + + +def test_harness_get_env_includes_cell_path(tmp_path: Path) -> None: + """get_env() returns a dict that includes MISSION_CELL_PATH and AGENT_NAME.""" + cell, harness = _make_cell_and_harness(tmp_path) + + # Load cell manually so get_env works without start() + from lazarus.cell import load_cell + harness._cell = load_cell(Path(cell.cell_path)) + + env = harness.get_env() + assert "MISSION_CELL_PATH" in env + assert env["MISSION_CELL_PATH"] == str(Path(cell.cell_path)) + assert env["AGENT_NAME"] == "testAgent" + assert "MISSION_ID" in env + assert "HOME" in env + + +def test_harness_get_env_uses_custom_home(tmp_path: Path) -> None: + """When home_path is provided, HOME in env equals that path.""" + cell, _ = _make_cell_and_harness(tmp_path) + custom_home = str(tmp_path / "wizard_home") + harness = MissionCellHarness( + agent_name="agentX", + cell_path=Path(cell.cell_path), + home_path=custom_home, + ) + + from lazarus.cell import load_cell + harness._cell = load_cell(Path(cell.cell_path)) + + env = harness.get_env() + assert env["HOME"] == custom_home + + +def test_harness_write_mission_log(tmp_path: Path) -> None: + """write_mission_log creates a log file in the cell's logs/ directory.""" + cell, harness = _make_cell_and_harness(tmp_path) + + from lazarus.cell import load_cell + harness._cell = load_cell(Path(cell.cell_path)) + + harness.write_mission_log("Hello from test") + + log_file = Path(cell.cell_path) / "logs" / "mission.log" + assert log_file.exists() + content = log_file.read_text() + assert "Hello from test" in content + assert "testAgent" in content + + +async def test_harness_start_emits_telemetry(tmp_path: Path) -> None: + """start() emits an agent_joined mission_event via WebSocket.""" + cell, harness = _make_cell_and_harness(tmp_path) + + mock_ws = AsyncMock() + mock_ws.send = AsyncMock() + mock_ws.close = AsyncMock() + + sent_messages = [] + + async def capture_send(payload: str) -> None: + sent_messages.append(json.loads(payload)) + + mock_ws.send.side_effect = capture_send + + with patch("lazarus.isolation.setup_level1_isolation"): + with patch("lazarus.harness.setup_level1_isolation"): + with patch("lazarus.harness.websockets") as mock_websockets: + mock_websockets.connect = AsyncMock(return_value=mock_ws) + harness._ws_connected = False + + await harness.start() + + assert any( + m.get("event") == "agent_joined" and m.get("type") == "mission_event" + for m in sent_messages + ), f"Expected agent_joined event; got: {sent_messages}" + + +async def test_harness_stop_emits_departure_telemetry(tmp_path: Path) -> None: + """stop() emits an agent_departed mission_event via WebSocket.""" + cell, harness = _make_cell_and_harness(tmp_path) + + from lazarus.cell import load_cell + harness._cell = load_cell(Path(cell.cell_path)) + + mock_ws = AsyncMock() + mock_ws.close = AsyncMock() + + sent_messages = [] + + async def capture_send(payload: str) -> None: + sent_messages.append(json.loads(payload)) + + mock_ws.send = AsyncMock(side_effect=capture_send) + harness._ws = mock_ws + harness._ws_connected = True + + await harness.stop() + + assert any( + m.get("event") == "agent_departed" + for m in sent_messages + ), f"Expected agent_departed event; got: {sent_messages}" diff --git a/tests/test_lazarus_isolation.py b/tests/test_lazarus_isolation.py new file mode 100644 index 00000000..fd992e25 --- /dev/null +++ b/tests/test_lazarus_isolation.py @@ -0,0 +1,107 @@ +"""Tests for lazarus.isolation — Level 1 isolation setup.""" + +from __future__ import annotations + +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from lazarus.cell import create_cell +from lazarus.isolation import ( + IsolationManifest, + load_apparmor_profile, + setup_level1_isolation, + write_apparmor_profile, +) + + +def _make_cell(tmp_path: Path, home_path: str = "") -> object: + return create_cell( + mission_id="iso-test-001", + name="Isolation Test", + repo_url="https://github.com/test/repo", + base_path=tmp_path / "cells", + home_path=home_path, + ) + + +def test_setup_isolation_creates_workspace(tmp_path: Path) -> None: + """setup_level1_isolation creates workspace and logs dirs.""" + cell = _make_cell(tmp_path) + manifest = setup_level1_isolation(cell) + + assert manifest.workspace_path.is_dir() + assert manifest.logs_path.is_dir() + assert isinstance(manifest, IsolationManifest) + assert manifest.cell_id == cell.id + + +def test_write_apparmor_profile_creates_file(tmp_path: Path) -> None: + """write_apparmor_profile writes a file at the given output path.""" + cell = _make_cell(tmp_path, home_path=str(tmp_path / "home" / "agentX")) + output_path = tmp_path / "test.apparmor" + + result_path = write_apparmor_profile(cell, output_path) + assert result_path == output_path + assert output_path.exists() + assert output_path.stat().st_size > 0 + + +def test_apparmor_profile_content(tmp_path: Path) -> None: + """Profile file contains correct cell ID, workspace, logs, and home paths.""" + home = str(tmp_path / "home" / "agentX") + cell = _make_cell(tmp_path, home_path=home) + output_path = tmp_path / "test.apparmor" + + write_apparmor_profile(cell, output_path) + content = output_path.read_text() + + assert cell.id in content + assert "nexus-mission-" in content + assert "workspace" in content + assert "logs" in content + assert home in content + # Deny write to home + assert "deny" in content + + +def test_load_apparmor_profile_noop_on_no_apparmor(tmp_path: Path) -> None: + """load_apparmor_profile returns False gracefully when apparmor_parser is absent.""" + profile_path = tmp_path / "dummy.apparmor" + profile_path.write_text("# fake profile\n") + + # Simulate apparmor_parser not found (FileNotFoundError from subprocess.run) + with patch("lazarus.isolation.platform.system", return_value="Linux"): + with patch( + "lazarus.isolation.subprocess.run", + side_effect=FileNotFoundError("apparmor_parser not found"), + ): + result = load_apparmor_profile(profile_path) + + assert result is False + + +def test_load_apparmor_profile_noop_on_non_linux(tmp_path: Path) -> None: + """load_apparmor_profile returns False on non-Linux platforms.""" + profile_path = tmp_path / "dummy.apparmor" + profile_path.write_text("# fake profile\n") + + with patch("lazarus.isolation.platform.system", return_value="Darwin"): + result = load_apparmor_profile(profile_path) + + assert result is False + + +def test_setup_isolation_writes_profile_when_home_path_set(tmp_path: Path) -> None: + """When home_path is set, setup_level1_isolation writes an AppArmor profile.""" + home = str(tmp_path / "home" / "agentZ") + cell = _make_cell(tmp_path, home_path=home) + + manifest = setup_level1_isolation(cell) + + assert manifest.apparmor_profile_path is not None + assert manifest.apparmor_profile_path.exists() diff --git a/tests/test_lazarus_roster.py b/tests/test_lazarus_roster.py new file mode 100644 index 00000000..bfa28341 --- /dev/null +++ b/tests/test_lazarus_roster.py @@ -0,0 +1,122 @@ +"""Tests for lazarus.roster — MissionRoster registry.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from lazarus.cell import create_cell +from lazarus.roster import MissionRoster + + +def _make_roster(tmp_path: Path) -> MissionRoster: + return MissionRoster(registry_path=tmp_path / "roster.json") + + +def _make_cell(tmp_path: Path, mission_id: str = "m-001") -> object: + return create_cell( + mission_id=mission_id, + name="Test Mission", + repo_url="https://github.com/test/repo", + base_path=tmp_path / "cells", + ) + + +def test_add_mission_and_invite(tmp_path: Path) -> None: + """Can add a mission and invite an agent to it.""" + roster = _make_roster(tmp_path) + cell = _make_cell(tmp_path) + + roster.add_mission(cell) + entry = roster.invite_agent(cell.id, "agentA", role="contributor") + + assert entry.agent_name == "agentA" + assert entry.status == "invited" + assert entry.role == "contributor" + assert entry.accepted_at is None + + +def test_accept_invitation(tmp_path: Path) -> None: + """Accepting an invitation changes status from 'invited' to 'active'.""" + roster = _make_roster(tmp_path) + cell = _make_cell(tmp_path) + + roster.add_mission(cell) + roster.invite_agent(cell.id, "agentB") + entry = roster.accept_invitation(cell.id, "agentB") + + assert entry.status == "active" + assert entry.accepted_at is not None + + +def test_depart_agent(tmp_path: Path) -> None: + """Departing changes status to 'departed'.""" + roster = _make_roster(tmp_path) + cell = _make_cell(tmp_path) + + roster.add_mission(cell) + roster.invite_agent(cell.id, "agentC") + roster.accept_invitation(cell.id, "agentC") + entry = roster.depart_agent(cell.id, "agentC") + + assert entry.status == "departed" + + +def test_list_missions_filtered_by_state(tmp_path: Path) -> None: + """list_missions(state=...) filters correctly.""" + roster = _make_roster(tmp_path) + cell1 = _make_cell(tmp_path, "m-001") + cell2 = _make_cell(tmp_path, "m-002") + + roster.add_mission(cell1) + roster.add_mission(cell2) + + # Both are 'pending' by default + pending = roster.list_missions(state="pending") + assert len(pending) == 2 + + # No active missions yet + active = roster.list_missions(state="active") + assert len(active) == 0 + + +def test_roster_persistence(tmp_path: Path) -> None: + """Save and reload the roster file; data survives the round-trip.""" + registry_path = tmp_path / "roster.json" + roster = MissionRoster(registry_path=registry_path) + cell = _make_cell(tmp_path) + + roster.add_mission(cell) + roster.invite_agent(cell.id, "agentD", role="lead") + roster.save() + + # Load fresh from disk + roster2 = MissionRoster(registry_path=registry_path) + mission = roster2.get_mission(cell.id) + assert mission is not None + assert mission["id"] == cell.id + + agents = roster2.list_agents(cell.id) + assert len(agents) == 1 + assert agents[0].agent_name == "agentD" + assert agents[0].role == "lead" + assert agents[0].status == "invited" + + +def test_list_agents(tmp_path: Path) -> None: + """list_agents returns all agents for a given mission.""" + roster = _make_roster(tmp_path) + cell = _make_cell(tmp_path) + + roster.add_mission(cell) + roster.invite_agent(cell.id, "alice") + roster.invite_agent(cell.id, "bob") + + agents = roster.list_agents(cell.id) + names = {a.agent_name for a in agents} + assert names == {"alice", "bob"}