diff --git a/docs/runbooks/MISSION_CORE_LOCAL_SERVICE_RECOVERY.md b/docs/runbooks/MISSION_CORE_LOCAL_SERVICE_RECOVERY.md index b14d22d..793a88c 100644 --- a/docs/runbooks/MISSION_CORE_LOCAL_SERVICE_RECOVERY.md +++ b/docs/runbooks/MISSION_CORE_LOCAL_SERVICE_RECOVERY.md @@ -72,6 +72,49 @@ waits until `bootout` has fully removed the old label, bootstraps the new declaration and accepts only the exact Mission Core health document. Any failure restores the previous plist and repeats the same health acceptance. +### Explicit worktree migration + +A plan from a different checkout is rejected by default. To migrate the +canonical service between these two exact worktrees, authorize the installed +predecessor explicitly on both plan and apply: + +```bash +cd /Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory + +uv run python scripts/manage_mission_core_launch_agent.py plan \ + --repository-root /Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory \ + --expected-current-repository-root /Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE +``` + +The plan is admissible only when `current_working_directory` is exactly +`/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE`, +`desired_working_directory` is exactly +`/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory` +and `changes.repository_migration` is `true`. The same plan must report +`preserved_data_directory` and `changes.preserved_data_directory` as exactly +`/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE/.runtime/mission-core`, +with `changes.data_directory_preserved=true`. The desired plist then carries +that exact path as `MISSIONCORE_DATA_DIR`; an already configured nonblank, +private canonical `MISSIONCORE_DATA_DIR` is retained instead. Copy the two +exact hashes from that same output: + +```bash +uv run python scripts/manage_mission_core_launch_agent.py apply \ + --repository-root /Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory \ + --expected-current-repository-root /Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE \ + --expected-current-sha256 \ + --expected-desired-sha256 +``` + +The migration option is not a general cross-repository override. The planner +resolves it to one exact path and rejects any installed `WorkingDirectory` +that differs. Apply recomputes the same path-bound plan and still requires both +plist hashes, so a changed predecessor or candidate must be planned again. +Do not symlink either worktree's `.runtime/mission-core` to the other. The +checkout-local singleton lock requires a real private directory; durable data +continuity is expressed only by the path-bound `MISSIONCORE_DATA_DIR` in this +migration plan. + Read-only status: ```bash diff --git a/scripts/manage_mission_core_launch_agent.py b/scripts/manage_mission_core_launch_agent.py index 08dc0ed..cb47345 100644 --- a/scripts/manage_mission_core_launch_agent.py +++ b/scripts/manage_mission_core_launch_agent.py @@ -28,6 +28,14 @@ def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("action", choices=("plan", "apply", "status")) parser.add_argument("--repository-root", type=Path, required=True) + parser.add_argument( + "--expected-current-repository-root", + type=Path, + help=( + "authorize migration only from this exact installed WorkingDirectory; " + "cross-repository plans remain denied when omitted" + ), + ) parser.add_argument( "--agent-path", type=Path, @@ -43,6 +51,7 @@ def main() -> int: plan = plan_mission_core_launch_agent( repository_root=arguments.repository_root, agent_path=arguments.agent_path, + expected_current_repository_root=arguments.expected_current_repository_root, ) if arguments.action == "plan": print(json.dumps(plan.to_dict(), indent=2, sort_keys=True)) diff --git a/src/k1link/local_service_launchd.py b/src/k1link/local_service_launchd.py index 3f9b4be..f55e95a 100644 --- a/src/k1link/local_service_launchd.py +++ b/src/k1link/local_service_launchd.py @@ -3,10 +3,12 @@ from __future__ import annotations import hashlib +import os import plistlib +import stat from dataclasses import dataclass from pathlib import Path -from typing import Final +from typing import Final, cast MISSION_CORE_LAUNCH_AGENT_LABEL: Final = "com.nodedc.mission-core.local" MISSION_CORE_LAUNCH_AGENT_SCHEMA: Final = "missioncore.local-launch-agent-plan/v1" @@ -21,6 +23,9 @@ class MissionCoreLaunchAgentPlan: agent_path: Path current_sha256: str desired_sha256: str + current_working_directory: Path + desired_working_directory: Path + preserved_data_directory: Path | None current_program_arguments: tuple[str, ...] desired_program_arguments: tuple[str, ...] desired_payload: bytes @@ -32,9 +37,24 @@ class MissionCoreLaunchAgentPlan: "agent_path": str(self.agent_path), "current_sha256": self.current_sha256, "desired_sha256": self.desired_sha256, + "current_working_directory": str(self.current_working_directory), + "desired_working_directory": str(self.desired_working_directory), + "preserved_data_directory": ( + str(self.preserved_data_directory) + if self.preserved_data_directory is not None + else None + ), "current_program_arguments": list(self.current_program_arguments), "desired_program_arguments": list(self.desired_program_arguments), "changes": { + "repository_migration": self.current_working_directory + != self.desired_working_directory, + "data_directory_preserved": self.preserved_data_directory is not None, + "preserved_data_directory": ( + str(self.preserved_data_directory) + if self.preserved_data_directory is not None + else None + ), "dependency_sync_disabled": "--no-sync" in self.desired_program_arguments, "self_health_watchdog": True, @@ -49,8 +69,14 @@ def plan_mission_core_launch_agent( *, repository_root: Path, agent_path: Path, + expected_current_repository_root: Path | None = None, ) -> MissionCoreLaunchAgentPlan: repository = repository_root.expanduser().resolve(strict=True) + expected_current_repository = ( + expected_current_repository_root.expanduser().resolve(strict=True) + if expected_current_repository_root is not None + else None + ) path = agent_path.expanduser().absolute() current_payload = _read_private_regular_file(path) try: @@ -61,14 +87,24 @@ def plan_mission_core_launch_agent( raise MissionCoreLaunchAgentError("current launch agent identity changed") current_arguments = _program_arguments(current) current_working_directory = current.get("WorkingDirectory") - if current_working_directory != str(repository): + if not isinstance(current_working_directory, str): + raise MissionCoreLaunchAgentError("current launch agent working directory is invalid") + if expected_current_repository is None and current_working_directory != str(repository): raise MissionCoreLaunchAgentError("current launch agent targets another repository") - environment = current.get("EnvironmentVariables") - if not isinstance(environment, dict) or any( + if ( + expected_current_repository is not None + and current_working_directory != str(expected_current_repository) + ): + raise MissionCoreLaunchAgentError( + "current launch agent does not target the expected current repository" + ) + environment_document = current.get("EnvironmentVariables") + if not isinstance(environment_document, dict) or any( not isinstance(key, str) or not isinstance(value, str) - for key, value in environment.items() + for key, value in environment_document.items() ): raise MissionCoreLaunchAgentError("current launch agent environment is invalid") + environment = cast(dict[str, str], environment_document) # A LaunchAgent started directly from this repository's venv is denied # access to ``.venv/pyvenv.cfg`` by macOS privacy controls because the # checkout is below Downloads. The Homebrew uv launcher is already the @@ -81,18 +117,31 @@ def plan_mission_core_launch_agent( or not uv_entrypoint.exists() ): raise MissionCoreLaunchAgentError("Mission Core uv entrypoint is unavailable") + current_repository = Path(current_working_directory) + repository_migration = current_repository != repository + preserved_data_directory = ( + _preserved_migration_data_directory( + current_repository=current_repository, + environment=environment, + ) + if repository_migration + else None + ) desired_environment = dict(environment) desired_environment["MISSIONCORE_SERVICE_WATCHDOG"] = "1" + if preserved_data_directory is not None: + desired_environment["MISSIONCORE_DATA_DIR"] = str(preserved_data_directory) log_path = repository / ".runtime/mission-core/k1link-serve-launchd.log" + desired_program_arguments = ( + str(uv_entrypoint), + "run", + "--no-sync", + "k1link", + "serve", + ) desired: dict[str, object] = { "Label": MISSION_CORE_LAUNCH_AGENT_LABEL, - "ProgramArguments": [ - str(uv_entrypoint), - "run", - "--no-sync", - "k1link", - "serve", - ], + "ProgramArguments": list(desired_program_arguments), "WorkingDirectory": str(repository), "EnvironmentVariables": desired_environment, "KeepAlive": True, @@ -109,8 +158,11 @@ def plan_mission_core_launch_agent( agent_path=path, current_sha256=_sha256(current_payload), desired_sha256=_sha256(desired_payload), + current_working_directory=current_repository, + desired_working_directory=repository, + preserved_data_directory=preserved_data_directory, current_program_arguments=current_arguments, - desired_program_arguments=tuple(desired["ProgramArguments"]), + desired_program_arguments=desired_program_arguments, desired_payload=desired_payload, ) @@ -122,6 +174,42 @@ def _program_arguments(document: dict[str, object]) -> tuple[str, ...]: return tuple(value) +def _preserved_migration_data_directory( + *, + current_repository: Path, + environment: dict[str, str], +) -> Path: + configured = environment.get("MISSIONCORE_DATA_DIR", "") + if configured.strip(): + if configured != configured.strip(): + raise MissionCoreLaunchAgentError( + "current Mission Core data directory is not a canonical absolute path" + ) + candidate = Path(configured) + else: + candidate = current_repository / ".runtime" / "mission-core" + if not candidate.is_absolute(): + raise MissionCoreLaunchAgentError( + "current Mission Core data directory is not a canonical absolute path" + ) + try: + resolved = candidate.resolve(strict=True) + metadata = candidate.lstat() + except OSError as exc: + raise MissionCoreLaunchAgentError( + "current Mission Core data directory is unavailable" + ) from exc + if resolved != candidate or ( + not stat.S_ISDIR(metadata.st_mode) + or stat.S_IMODE(metadata.st_mode) != 0o700 + or metadata.st_uid != os.getuid() + ): + raise MissionCoreLaunchAgentError( + "current Mission Core data directory is not a private canonical directory" + ) + return resolved + + def _read_private_regular_file(path: Path) -> bytes: if path.is_symlink() or not path.is_file(): raise MissionCoreLaunchAgentError("Mission Core launch agent is unavailable") diff --git a/tests/test_local_service_launchd.py b/tests/test_local_service_launchd.py index e40195b..aa0ac84 100644 --- a/tests/test_local_service_launchd.py +++ b/tests/test_local_service_launchd.py @@ -3,7 +3,32 @@ from __future__ import annotations import plistlib from pathlib import Path -from k1link.local_service_launchd import plan_mission_core_launch_agent +import pytest + +from k1link.local_service_launchd import ( + MissionCoreLaunchAgentError, + plan_mission_core_launch_agent, +) + + +def _write_agent( + *, + path: Path, + repository: Path, + uv_entrypoint: Path, + environment: dict[str, str] | None = None, +) -> None: + path.write_bytes( + plistlib.dumps( + { + "Label": "com.nodedc.mission-core.local", + "ProgramArguments": [str(uv_entrypoint), "run", "k1link", "serve"], + "WorkingDirectory": str(repository), + "EnvironmentVariables": environment or {"PATH": "/usr/bin:/bin"}, + } + ) + ) + path.chmod(0o600) def test_launch_agent_plan_disables_sync_and_enables_watchdog(tmp_path: Path) -> None: @@ -13,17 +38,7 @@ def test_launch_agent_plan_disables_sync_and_enables_watchdog(tmp_path: Path) -> uv_entrypoint.write_text("#!/bin/sh\n") uv_entrypoint.chmod(0o700) agent = tmp_path / "agent.plist" - agent.write_bytes( - plistlib.dumps( - { - "Label": "com.nodedc.mission-core.local", - "ProgramArguments": [str(uv_entrypoint), "run", "k1link", "serve"], - "WorkingDirectory": str(repository), - "EnvironmentVariables": {"PATH": "/usr/bin:/bin"}, - } - ) - ) - agent.chmod(0o600) + _write_agent(path=agent, repository=repository, uv_entrypoint=uv_entrypoint) plan = plan_mission_core_launch_agent( repository_root=repository, @@ -44,3 +59,190 @@ def test_launch_agent_plan_disables_sync_and_enables_watchdog(tmp_path: Path) -> assert desired["AbandonProcessGroup"] is False assert desired["ExitTimeOut"] == 20 assert plan.current_sha256 != plan.desired_sha256 + assert plan.current_working_directory == repository + assert plan.desired_working_directory == repository + assert plan.preserved_data_directory is None + assert "MISSIONCORE_DATA_DIR" not in desired["EnvironmentVariables"] + assert plan.to_dict()["changes"]["repository_migration"] is False + assert plan.to_dict()["changes"]["data_directory_preserved"] is False + + +def test_launch_agent_plan_rejects_cross_repository_migration_by_default( + tmp_path: Path, +) -> None: + current_repository = tmp_path / "current-repo" + current_repository.mkdir() + desired_repository = tmp_path / "desired-repo" + desired_repository.mkdir() + uv_entrypoint = tmp_path / "uv" + uv_entrypoint.write_text("#!/bin/sh\n") + uv_entrypoint.chmod(0o700) + agent = tmp_path / "agent.plist" + _write_agent( + path=agent, + repository=current_repository, + uv_entrypoint=uv_entrypoint, + ) + + with pytest.raises( + MissionCoreLaunchAgentError, + match="current launch agent targets another repository", + ): + plan_mission_core_launch_agent( + repository_root=desired_repository, + agent_path=agent, + ) + + +def test_launch_agent_plan_binds_explicit_cross_repository_migration( + tmp_path: Path, +) -> None: + current_repository = tmp_path / "current-repo" + current_repository.mkdir() + desired_repository = tmp_path / "desired-repo" + desired_repository.mkdir() + other_repository = tmp_path / "other-repo" + other_repository.mkdir() + data_directory = current_repository / ".runtime" / "mission-core" + data_directory.mkdir(parents=True, mode=0o700) + uv_entrypoint = tmp_path / "uv" + uv_entrypoint.write_text("#!/bin/sh\n") + uv_entrypoint.chmod(0o700) + agent = tmp_path / "agent.plist" + _write_agent( + path=agent, + repository=current_repository, + uv_entrypoint=uv_entrypoint, + ) + + with pytest.raises( + MissionCoreLaunchAgentError, + match="does not target the expected current repository", + ): + plan_mission_core_launch_agent( + repository_root=desired_repository, + agent_path=agent, + expected_current_repository_root=other_repository, + ) + + plan = plan_mission_core_launch_agent( + repository_root=desired_repository, + agent_path=agent, + expected_current_repository_root=current_repository, + ) + desired = plistlib.loads(plan.desired_payload) + document = plan.to_dict() + + assert plan.current_working_directory == current_repository + assert plan.desired_working_directory == desired_repository + assert plan.preserved_data_directory == data_directory + assert desired["WorkingDirectory"] == str(desired_repository) + assert desired["EnvironmentVariables"]["MISSIONCORE_DATA_DIR"] == str(data_directory) + assert document["current_working_directory"] == str(current_repository) + assert document["desired_working_directory"] == str(desired_repository) + assert document["preserved_data_directory"] == str(data_directory) + assert document["changes"]["repository_migration"] is True + assert document["changes"]["data_directory_preserved"] is True + assert document["changes"]["preserved_data_directory"] == str(data_directory) + + +def test_launch_agent_migration_retains_existing_private_data_directory( + tmp_path: Path, +) -> None: + current_repository = tmp_path / "current-repo" + current_repository.mkdir() + desired_repository = tmp_path / "desired-repo" + desired_repository.mkdir() + configured_data_directory = tmp_path / "canonical-data" + configured_data_directory.mkdir(mode=0o700) + uv_entrypoint = tmp_path / "uv" + uv_entrypoint.write_text("#!/bin/sh\n") + uv_entrypoint.chmod(0o700) + agent = tmp_path / "agent.plist" + _write_agent( + path=agent, + repository=current_repository, + uv_entrypoint=uv_entrypoint, + environment={ + "PATH": "/usr/bin:/bin", + "MISSIONCORE_DATA_DIR": str(configured_data_directory), + }, + ) + + plan = plan_mission_core_launch_agent( + repository_root=desired_repository, + agent_path=agent, + expected_current_repository_root=current_repository, + ) + desired = plistlib.loads(plan.desired_payload) + + assert plan.preserved_data_directory == configured_data_directory + assert ( + desired["EnvironmentVariables"]["MISSIONCORE_DATA_DIR"] + == str(configured_data_directory) + ) + + +def test_launch_agent_migration_rejects_symlinked_data_directory(tmp_path: Path) -> None: + current_repository = tmp_path / "current-repo" + current_repository.mkdir() + desired_repository = tmp_path / "desired-repo" + desired_repository.mkdir() + private_directory = tmp_path / "private-data" + private_directory.mkdir(mode=0o700) + symlinked_directory = tmp_path / "data-link" + symlinked_directory.symlink_to(private_directory, target_is_directory=True) + uv_entrypoint = tmp_path / "uv" + uv_entrypoint.write_text("#!/bin/sh\n") + uv_entrypoint.chmod(0o700) + agent = tmp_path / "agent.plist" + _write_agent( + path=agent, + repository=current_repository, + uv_entrypoint=uv_entrypoint, + environment={ + "PATH": "/usr/bin:/bin", + "MISSIONCORE_DATA_DIR": str(symlinked_directory), + }, + ) + + with pytest.raises( + MissionCoreLaunchAgentError, + match="data directory is not a private canonical directory", + ): + plan_mission_core_launch_agent( + repository_root=desired_repository, + agent_path=agent, + expected_current_repository_root=current_repository, + ) + + +def test_launch_agent_migration_rejects_nonprivate_default_data_directory( + tmp_path: Path, +) -> None: + current_repository = tmp_path / "current-repo" + current_repository.mkdir() + desired_repository = tmp_path / "desired-repo" + desired_repository.mkdir() + data_directory = current_repository / ".runtime" / "mission-core" + data_directory.mkdir(parents=True, mode=0o755) + data_directory.chmod(0o755) + uv_entrypoint = tmp_path / "uv" + uv_entrypoint.write_text("#!/bin/sh\n") + uv_entrypoint.chmod(0o700) + agent = tmp_path / "agent.plist" + _write_agent( + path=agent, + repository=current_repository, + uv_entrypoint=uv_entrypoint, + ) + + with pytest.raises( + MissionCoreLaunchAgentError, + match="data directory is not a private canonical directory", + ): + plan_mission_core_launch_agent( + repository_root=desired_repository, + agent_path=agent, + expected_current_repository_root=current_repository, + ) diff --git a/tests/test_manage_mission_core_launch_agent.py b/tests/test_manage_mission_core_launch_agent.py index de291de..7bf43dc 100644 --- a/tests/test_manage_mission_core_launch_agent.py +++ b/tests/test_manage_mission_core_launch_agent.py @@ -1,7 +1,10 @@ from __future__ import annotations import importlib.util +import json +import plistlib import subprocess +import sys from pathlib import Path from typing import Any @@ -15,6 +18,57 @@ manager = importlib.util.module_from_spec(_SPEC) _SPEC.loader.exec_module(manager) +def test_plan_cli_authorizes_only_the_declared_current_repository( + monkeypatch: Any, + capsys: Any, + tmp_path: Path, +) -> None: + current_repository = tmp_path / "current-repo" + current_repository.mkdir() + desired_repository = tmp_path / "desired-repo" + desired_repository.mkdir() + data_directory = current_repository / ".runtime" / "mission-core" + data_directory.mkdir(parents=True, mode=0o700) + uv_entrypoint = tmp_path / "uv" + uv_entrypoint.write_text("#!/bin/sh\n") + uv_entrypoint.chmod(0o700) + agent = tmp_path / "agent.plist" + agent.write_bytes( + plistlib.dumps( + { + "Label": "com.nodedc.mission-core.local", + "ProgramArguments": [str(uv_entrypoint), "run", "k1link", "serve"], + "WorkingDirectory": str(current_repository), + "EnvironmentVariables": {"PATH": "/usr/bin:/bin"}, + } + ) + ) + agent.chmod(0o600) + monkeypatch.setattr( + sys, + "argv", + [ + str(_SCRIPT), + "plan", + "--repository-root", + str(desired_repository), + "--expected-current-repository-root", + str(current_repository), + "--agent-path", + str(agent), + ], + ) + + assert manager.main() == 0 + + document = json.loads(capsys.readouterr().out) + assert document["current_working_directory"] == str(current_repository) + assert document["desired_working_directory"] == str(desired_repository) + assert document["preserved_data_directory"] == str(data_directory) + assert document["changes"]["repository_migration"] is True + assert document["changes"]["data_directory_preserved"] is True + + def test_reload_waits_for_launchd_transition_before_bootstrap( monkeypatch: Any, tmp_path: Path,