Compare commits
1 Commits
main
...
claude/iss
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e530565d3c |
1
lazarus/__init__.py
Normal file
1
lazarus/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Lazarus Pit — Agent resurrection pool and mission cell isolation."""
|
||||
BIN
lazarus/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
lazarus/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
lazarus/__pycache__/cell.cpython-312.pyc
Normal file
BIN
lazarus/__pycache__/cell.cpython-312.pyc
Normal file
Binary file not shown.
BIN
lazarus/__pycache__/cli.cpython-312.pyc
Normal file
BIN
lazarus/__pycache__/cli.cpython-312.pyc
Normal file
Binary file not shown.
BIN
lazarus/__pycache__/harness.cpython-312.pyc
Normal file
BIN
lazarus/__pycache__/harness.cpython-312.pyc
Normal file
Binary file not shown.
BIN
lazarus/__pycache__/isolation.cpython-312.pyc
Normal file
BIN
lazarus/__pycache__/isolation.cpython-312.pyc
Normal file
Binary file not shown.
BIN
lazarus/__pycache__/roster.cpython-312.pyc
Normal file
BIN
lazarus/__pycache__/roster.cpython-312.pyc
Normal file
Binary file not shown.
145
lazarus/cell.py
Normal file
145
lazarus/cell.py
Normal file
@@ -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
|
||||
162
lazarus/cli.py
Normal file
162
lazarus/cli.py
Normal file
@@ -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())
|
||||
165
lazarus/harness.py
Normal file
165
lazarus/harness.py
Normal file
@@ -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/<uuid>")
|
||||
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,
|
||||
)
|
||||
170
lazarus/isolation.py
Normal file
170
lazarus/isolation.py
Normal file
@@ -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 <tunables/global>
|
||||
|
||||
profile nexus-mission-{cell_id} flags=(attach_disconnected,mediate_deleted) {{
|
||||
#include <abstractions/base>
|
||||
|
||||
# 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
|
||||
184
lazarus/roster.py
Normal file
184
lazarus/roster.py
Normal file
@@ -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"
|
||||
)
|
||||
110
tests/test_lazarus_cell.py
Normal file
110
tests/test_lazarus_cell.py
Normal file
@@ -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"
|
||||
136
tests/test_lazarus_cli.py
Normal file
136
tests/test_lazarus_cli.py
Normal file
@@ -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"
|
||||
141
tests/test_lazarus_harness.py
Normal file
141
tests/test_lazarus_harness.py
Normal file
@@ -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}"
|
||||
107
tests/test_lazarus_isolation.py
Normal file
107
tests/test_lazarus_isolation.py
Normal file
@@ -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()
|
||||
122
tests/test_lazarus_roster.py
Normal file
122
tests/test_lazarus_roster.py
Normal file
@@ -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"}
|
||||
Reference in New Issue
Block a user