diff --git a/.github/workflows/engineering-platform-validation.yml b/.github/workflows/engineering-platform-validation.yml index 1a62b4e7..69b55225 100644 --- a/.github/workflows/engineering-platform-validation.yml +++ b/.github/workflows/engineering-platform-validation.yml @@ -100,8 +100,8 @@ jobs: measured_modules = {Path(path).resolve() for path in files} unmeasured = [str(path) for path in production_modules if path not in measured_modules] aggregate = report["totals"]["percent_covered"] - if len(production_modules) != 107: - failures.append(f"production module inventory changed: {len(production_modules)} (expected 107)") +if len(production_modules) != 108: + failures.append(f"production module inventory changed: {len(production_modules)} (expected 108)") if unmeasured: failures.append("unmeasured production modules: " + ", ".join(unmeasured)) if aggregate < 80.00: diff --git a/docs/engineering/STANDALONE_EP_SERVER_FOUNDATION.md b/docs/engineering/STANDALONE_EP_SERVER_FOUNDATION.md index 6b566277..c1b832d8 100644 --- a/docs/engineering/STANDALONE_EP_SERVER_FOUNDATION.md +++ b/docs/engineering/STANDALONE_EP_SERVER_FOUNDATION.md @@ -10,6 +10,8 @@ engineering-platform-server init --data-root /secure/ep-server engineering-platform-server start --data-root /secure/ep-server engineering-platform-server health --data-root /secure/ep-server engineering-platform-server stop --data-root /secure/ep-server +engineering-platform-server service-install --data-root /secure/ep-server +engineering-platform-server service-uninstall --data-root /secure/ep-server engineering-platform-server relay-install --data-root /secure/ep-server ``` @@ -35,6 +37,12 @@ projection at `GET /v1/operations/projects`. See [Standalone runtime surfaces](STANDALONE_RUNTIME_SURFACES.md) for the complete installed-artifact authority and role contract. +`service-install` installs exactly one per-user macOS LaunchAgent for the +foreground `serve` entrypoint. Its fixed arguments use the installed Python +runtime and initialized data root; it never invokes the self-daemonizing +`start` helper or accepts arbitrary commands. `service-uninstall` removes only +that owned LaunchAgent and preserves CENTRAL and installation identity. + The existing Execution Host remains unchanged and retains its current execution authority. The server does not read a source checkout, `.engineering`, or any DJConnect state at runtime. diff --git a/src/engineering_platform/server.py b/src/engineering_platform/server.py index 6854e247..f45c7d03 100644 --- a/src/engineering_platform/server.py +++ b/src/engineering_platform/server.py @@ -46,6 +46,7 @@ from . import project_topology from . import submission_service from . import server_relay +from . import server_service from . import storage from . import managed_codex_runtime from . import provider_readiness @@ -3112,7 +3113,7 @@ def health(data_root: Path) -> dict[str, object]: def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(prog="engineering-platform-server", description="Manage the standalone Engineering Platform Server foundation") - parser.add_argument("command", choices=("init", "start", "serve", "stop", "status", "health", "relay-install", "relay-uninstall", "pairing-create", "agent-status", "agent-revoke", "agent-reset", "topology", "submission-diagnose", "bootstrap-topology", "register-topology", "provision-declaration", "issue-consumer-credential", "bind-repository", "rebind-repository", "unbind-repository", "resolve-repository", "register-producer-binding", "list-producer-bindings", "deactivate-producer-binding")) + parser.add_argument("command", choices=("init", "start", "serve", "stop", "status", "health", "service-install", "service-uninstall", "relay-install", "relay-uninstall", "pairing-create", "agent-status", "agent-revoke", "agent-reset", "topology", "submission-diagnose", "bootstrap-topology", "register-topology", "provision-declaration", "issue-consumer-credential", "bind-repository", "rebind-repository", "unbind-repository", "resolve-repository", "register-producer-binding", "list-producer-bindings", "deactivate-producer-binding")) parser.add_argument("--data-root", type=Path, default=default_data_root()) parser.add_argument("--bind-host", default="127.0.0.1") parser.add_argument("--bind-port", type=int, default=8765) @@ -3150,6 +3151,10 @@ def main(argv: list[str] | None = None) -> int: elif args.command == "stop": result = stop(args.data_root) elif args.command == "status": result = status(args.data_root) elif args.command == "health": result = health(args.data_root) + elif args.command == "service-install": + initialize(args.data_root) + result = {"result": "INSTALLED", **server_service.install(args.data_root)} + elif args.command == "service-uninstall": result = {"result": "UNINSTALLED", **server_service.uninstall(args.data_root)} elif args.command == "relay-install": initialize(args.data_root) result = {"result": "INSTALLED", **server_relay.install(args.data_root)} diff --git a/src/engineering_platform/server_service.py b/src/engineering_platform/server_service.py new file mode 100644 index 00000000..ce3932c7 --- /dev/null +++ b/src/engineering_platform/server_service.py @@ -0,0 +1,110 @@ +"""macOS LaunchAgent lifecycle for the installed EP Server. + +The Server remains the lifecycle and CENTRAL authority. This module only +installs one fixed per-user supervisor for its foreground ``serve`` command; +it never starts a second daemon or accepts arbitrary commands. +""" +from __future__ import annotations + +from dataclasses import dataclass +import os +from pathlib import Path +import plistlib +import platform +import subprocess +import sys +from typing import Callable, Mapping, Sequence + + +LABEL = "com.engineeringplatform.server" +DEFAULT_PATH = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" + + +class ServerServiceError(ValueError): + """Raised when the bounded EP Server service cannot be managed safely.""" + + +@dataclass(frozen=True) +class ServicePaths: + data_root: Path + launch_agents_dir: Path + + @property + def plist_path(self) -> Path: + return self.launch_agents_dir / f"{LABEL}.plist" + + +def default_paths(data_root: Path, home: Path | None = None) -> ServicePaths: + return ServicePaths(data_root.resolve(), (home or Path.home()).expanduser() / "Library" / "LaunchAgents") + + +def _domain() -> str: + return f"gui/{os.getuid()}" + + +Runner = Callable[[Sequence[str]], subprocess.CompletedProcess[str]] + + +def _launchctl(arguments: Sequence[str], runner: Runner | None = None) -> subprocess.CompletedProcess[str]: + if platform.system() != "Darwin": + raise ServerServiceError("EP Server LaunchAgent lifecycle is supported only on macOS.") + return (runner or (lambda command: subprocess.run(command, capture_output=True, text=True, check=False)))(["launchctl", *arguments]) + + +def _installed_interpreter(candidate: str | Path | None = None) -> Path: + executable = Path(candidate or sys.executable).expanduser().resolve() + if not executable.is_file() or not os.access(executable, os.X_OK): + raise ServerServiceError("The EP Server interpreter is not an executable installed runtime.") + return executable + + +def plist_payload(paths: ServicePaths, interpreter: Path) -> dict[str, object]: + return { + "Label": LABEL, + "ProgramArguments": [str(interpreter), "-m", "engineering_platform.server", "serve", "--data-root", str(paths.data_root)], + "WorkingDirectory": str(paths.data_root), + "RunAtLoad": True, + "KeepAlive": {"SuccessfulExit": False}, + "ProcessType": "Background", + "EnvironmentVariables": { + "PATH": DEFAULT_PATH, + "PYTHONNOUSERSITE": "1", + "PYTHONSAFEPATH": "1", + "EP_SERVER_DATA_ROOT": str(paths.data_root), + }, + "StandardOutPath": "/dev/null", + "StandardErrorPath": "/dev/null", + } + + +def write_plist(paths: ServicePaths, interpreter: Path) -> Path: + paths.launch_agents_dir.mkdir(mode=0o700, parents=True, exist_ok=True) + content = plistlib.dumps(plist_payload(paths, interpreter), fmt=plistlib.FMT_XML, sort_keys=True) + temporary = paths.plist_path.with_suffix(".plist.tmp") + temporary.write_bytes(content) + temporary.chmod(0o644) + os.replace(temporary, paths.plist_path) + paths.plist_path.chmod(0o644) + return paths.plist_path + + +def install(data_root: Path, *, interpreter: str | Path | None = None, home: Path | None = None, + runner: Runner | None = None) -> Mapping[str, str]: + paths = default_paths(data_root, home) + if not paths.data_root.is_dir() or not (paths.data_root / "server.json").is_file(): + raise ServerServiceError("EP Server must be initialized before its LaunchAgent is installed.") + plist = write_plist(paths, _installed_interpreter(interpreter)) + result = _launchctl(("bootstrap", _domain(), str(plist)), runner) + if result.returncode and "service already loaded" not in (result.stderr or "").lower(): + raise ServerServiceError("Unable to bootstrap EP Server LaunchAgent.") + return {"state": "installed", "label": LABEL, "plist": str(plist), "data_root": str(paths.data_root)} + + +def uninstall(data_root: Path, *, home: Path | None = None, runner: Runner | None = None) -> Mapping[str, str]: + paths = default_paths(data_root, home) + if paths.plist_path.exists(): + result = _launchctl(("bootout", _domain(), str(paths.plist_path)), runner) + if result.returncode and not any(marker in (result.stderr or "").lower() for marker in ("could not find service", "no such process", "not found")): + raise ServerServiceError("Unable to unload EP Server LaunchAgent.") + paths.plist_path.unlink(missing_ok=True) + return {"state": "uninstalled", "label": LABEL, "plist": str(paths.plist_path), "data_root": str(paths.data_root)} diff --git a/tests/engineering/test_server_foundation.py b/tests/engineering/test_server_foundation.py index 707d9cf5..c3335923 100644 --- a/tests/engineering/test_server_foundation.py +++ b/tests/engineering/test_server_foundation.py @@ -478,6 +478,16 @@ def test_server_cli_installs_relay_through_server_owned_lifecycle(self) -> None: self.assertEqual(result["result"], "INSTALLED") self.assertEqual(result["component"], "dashboard_relay") + def test_server_cli_installs_server_launchagent_through_server_owned_lifecycle(self) -> None: + with patch("engineering_platform.server.server_service.install", return_value={ + "state": "installed", "label": "com.engineeringplatform.server", "plist": "/Library/LaunchAgents/com.engineeringplatform.server.plist", "data_root": "/installation", + }) as install, redirect_stdout(io.StringIO()) as output: + self.assertEqual(server.main(["service-install", "--data-root", str(self.root)]), 0) + install.assert_called_once_with(self.root) + result = json.loads(output.getvalue()) + self.assertEqual(result["result"], "INSTALLED") + self.assertEqual(result["label"], "com.engineeringplatform.server") + def test_live_file_inbox_with_quarantine_is_degraded_without_execution_state(self) -> None: server.initialize(self.root) inbox = self.root / server.FILE_INBOX_DIRECTORY diff --git a/tests/engineering/test_server_service.py b/tests/engineering/test_server_service.py new file mode 100644 index 00000000..018e7e52 --- /dev/null +++ b/tests/engineering/test_server_service.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from pathlib import Path +from tempfile import TemporaryDirectory +import subprocess +import sys +import unittest +from unittest.mock import patch + +from engineering_platform import server_service + + +class ServerServiceTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = TemporaryDirectory() + self.root = Path(self.temporary.name) / "instance" + self.home = Path(self.temporary.name) / "home" + self.root.mkdir() + (self.root / "server.json").write_text("{}", encoding="utf-8") + + def tearDown(self) -> None: + self.temporary.cleanup() + + @staticmethod + def runner(arguments: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess(arguments, 0, "", "") + + def test_payload_runs_foreground_server_from_absolute_interpreter(self) -> None: + paths = server_service.default_paths(self.root, self.home) + payload = server_service.plist_payload(paths, Path("/runtime/bin/python")) + self.assertEqual(payload["Label"], server_service.LABEL) + self.assertEqual(payload["ProgramArguments"], ["/runtime/bin/python", "-m", "engineering_platform.server", "serve", "--data-root", str(self.root.resolve())]) + self.assertEqual(payload["WorkingDirectory"], str(self.root.resolve())) + self.assertNotIn("PYTHONPATH", payload["EnvironmentVariables"]) + + def test_install_is_idempotent_and_writes_only_owned_plist(self) -> None: + with patch("engineering_platform.server_service.platform.system", return_value="Darwin"): + result = server_service.install(self.root, interpreter=Path(sys.executable), home=self.home, runner=self.runner) + self.assertEqual(result["label"], server_service.LABEL) + self.assertTrue((self.home / "Library" / "LaunchAgents" / f"{server_service.LABEL}.plist").is_file()) + + def test_uninitialized_data_root_fails_closed(self) -> None: + with self.assertRaisesRegex(server_service.ServerServiceError, "initialized"): + server_service.install(self.root.parent / "missing", interpreter=Path(__file__).resolve(), home=self.home, runner=self.runner) + + def test_uninstall_boots_out_only_the_owned_agent(self) -> None: + paths = server_service.default_paths(self.root, self.home) + paths.launch_agents_dir.mkdir(parents=True) + paths.plist_path.write_text("owned", encoding="utf-8") + calls: list[list[str]] = [] + + def runner(arguments: list[str]) -> subprocess.CompletedProcess[str]: + calls.append(arguments) + return subprocess.CompletedProcess(arguments, 0, "", "") + + with patch("engineering_platform.server_service.platform.system", return_value="Darwin"): + result = server_service.uninstall(self.root, home=self.home, runner=runner) + self.assertEqual(result["state"], "uninstalled") + self.assertFalse(paths.plist_path.exists()) + self.assertEqual(calls, [["launchctl", "bootout", f"gui/{server_service.os.getuid()}", str(paths.plist_path)]]) + + def test_launchagent_failures_and_non_macos_fail_closed(self) -> None: + with patch("engineering_platform.server_service.platform.system", return_value="Darwin"): + def failed(arguments: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess(arguments, 1, "", "permission denied") + with self.assertRaisesRegex(server_service.ServerServiceError, "Unable to bootstrap"): + server_service.install(self.root, interpreter=Path(sys.executable), home=self.home, runner=failed) + with patch("engineering_platform.server_service.platform.system", return_value="Linux"): + with self.assertRaisesRegex(server_service.ServerServiceError, "macOS"): + server_service.uninstall(self.root, home=self.home, runner=self.runner)