#!/usr/bin/env python3 """Enforcement-side version-coupling gate (issue #869, Point-1 card C4). Root cause this exists to guard against (#828 version skew, restated from the C1 activation probe in ``lease-activation-probe.ts``): the lease broker's ENFORCEMENT half (this toolkit — ``launch-runtime.py``, ``mutator-gate.py``, ``revoke-lease.py``) and its ACTIVATION half (``execLeaseGatedRuntime()`` in ``launch.ts``, which chains the gated runtime through ``launch-runtime.py`` and injects ``MOSAIC_LEASE_*``) ship on different channels — an npm package and a framework/CLI reseed. C1 gave the activation half a versioned, machine-checkable identity (``LEASE_ACTIVATION_CAPABILITY``, printed by the CLI's hidden ``mosaic __lease-capability`` subcommand). That identity is inert on its own: nothing yet asserted that ENFORCEMENT actually requires the version ACTIVATION advertises. This module is that assertion, owned by the enforcement side. ``EXPECTED_ACTIVATION_CAPABILITY`` below is this toolkit's own contract declaration — bump it only when this toolkit's launch/gate seam starts requiring a different activation contract (new env vars it depends on, changed chaining behavior, etc.), independent of any package semver, for the same reason C1's constant is: #828 happened precisely because a version number that should have moved did not. This module never talks to a real broker or a real installed CLI in its own tests — both the probe's command resolution and its ``run`` transport are injectable so tests can drive every branch with fakes/stubs (see ``version_coupling_unittest.py``). """ from __future__ import annotations import json import os import shlex import shutil import subprocess from collections.abc import Callable, Mapping from typing import Final, TypedDict class ActivationCapability(TypedDict): name: str version: int # ENFORCEMENT-side expected activation contract. OWNED by this toolkit (the # enforcement half). Mirrors — but is deliberately a SEPARATE constant from # — `LEASE_ACTIVATION_CAPABILITY` in # `packages/mosaic/src/commands/lease-activation-probe.ts` (the activation # half's own declaration of what it implements). The two are compared at # runtime by `assert_activation_capability_matches()`; drift between them is # exactly the version-skew failure mode #828/#869 exist to catch, and must # FAIL LOUD, never a silent pass and never a dead (always-true) gate. EXPECTED_ACTIVATION_CAPABILITY: Final[ActivationCapability] = { "name": "lease-runtime-activation", "version": 1, } # Matches `LEASE_CAPABILITY_PROBE_COMMAND` in lease-activation-probe.ts — # the hidden CLI subcommand that prints the activation half's advertised # capability as compact JSON. LEASE_CAPABILITY_PROBE_COMMAND: Final = "__lease-capability" PROBE_TIMEOUT_SECONDS: Final = 2.0 # Override hook: a full shell-style command line (parsed with `shlex.split`) # to run INSTEAD of resolving `mosaic` on PATH and appending the probe # subcommand. Real deployments should never need this — `mosaic` is on PATH # whenever a runtime was launched via `mosaic ` in the first place, the # only real caller of this seam. It exists for integration tests that spawn # `launch-runtime.py` directly (never through the real CLI) to supply a # fake/stub CLI probe, matching the existing convention of those tests # supplying a fake broker and a fake runtime binary rather than depending on # host state. MOSAIC_COMMAND_OVERRIDE_VAR: Final = "MOSAIC_LEASE_VERSION_PROBE_COMMAND" class VersionCouplingError(Exception): """Raised when the activation capability is absent, unreadable, or does not match what enforcement expects. Callers MUST fail loud on this (non-zero exit, clear actionable stderr) — never swallow it into a silent pass, and never let its absence be treated as compatible.""" def _resolve_probe_command(environ: Mapping[str, str]) -> list[str] | None: override = environ.get(MOSAIC_COMMAND_OVERRIDE_VAR) if override: parsed = shlex.split(override) return parsed or None resolved = shutil.which("mosaic") if resolved is None: return None return [resolved, LEASE_CAPABILITY_PROBE_COMMAND] def default_probe_activation_capability( environ: Mapping[str, str] | None = None, *, run: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run, ) -> ActivationCapability | None: """Real capability lookup: resolves and executes the CLI's hidden ``__lease-capability`` probe subcommand out-of-process (the same mechanism `defaultCapabilityProbe()` in lease-activation-probe.ts uses from the activation side) and parses its JSON stdout. Any failure to resolve a command, spawn it, have it exit zero, or produce a well-shaped ``{name, version}`` JSON object is treated as NO capability (``None``) — fail-closed, never a fabricated/guessed capability. """ source_environment = os.environ if environ is None else environ command = _resolve_probe_command(source_environment) if command is None: return None try: completed = run( command, capture_output=True, text=True, timeout=PROBE_TIMEOUT_SECONDS, check=False, ) except (OSError, subprocess.TimeoutExpired, ValueError): return None if completed.returncode != 0: return None try: parsed = json.loads(completed.stdout) except json.JSONDecodeError: return None if ( not isinstance(parsed, dict) or not isinstance(parsed.get("name"), str) or not isinstance(parsed.get("version"), int) or isinstance(parsed.get("version"), bool) ): return None return {"name": parsed["name"], "version": parsed["version"]} def format_mismatch_message( activation: ActivationCapability | None, expected: ActivationCapability, ) -> str: """Actionable, non-silent remediation message for either failure shape: absent/unreadable capability, or a present-but-incompatible one.""" if activation is None: return ( "Mosaic lease activation capability unreadable: enforcement " f"expects '{expected['name']}' v{expected['version']} but the " f"CLI's `mosaic {LEASE_CAPABILITY_PROBE_COMMAND}` probe produced " "no usable result (mosaic not on PATH, non-zero exit, or " "malformed output) — framework/CLI version skew; upgrade both " "as one unit; see #869." ) if activation["name"] != expected["name"]: return ( f"activation capability name '{activation['name']}' != " f"enforcement expects '{expected['name']}' — framework/CLI " "version skew; upgrade both as one unit; see #869" ) return ( f"activation capability v{activation['version']} != enforcement " f"expects v{expected['version']} — framework/CLI version skew; " "upgrade both as one unit; see #869" ) def assert_activation_capability_matches( activation: ActivationCapability | None, expected: ActivationCapability = EXPECTED_ACTIVATION_CAPABILITY, ) -> None: """Raise `VersionCouplingError` unless `activation` is present AND its `name`/`version` exactly match `expected`. Absence is treated the same as a mismatch — never a silent pass.""" if ( activation is None or activation.get("name") != expected["name"] or activation.get("version") != expected["version"] ): raise VersionCouplingError(format_mismatch_message(activation, expected))