diff --git a/loopx/extensions/presentation.py b/loopx/extensions/presentation.py index 922cb79d3..5d56a2343 100644 --- a/loopx/extensions/presentation.py +++ b/loopx/extensions/presentation.py @@ -21,7 +21,11 @@ run_standalone_extension, ) from .process_runtime import run_capped_process -from .readiness import ResolvedRuntimeEntrypoint, runtime_process_environment +from .readiness import ( + CORE_VIEW_VALIDATORS, + ResolvedRuntimeEntrypoint, + runtime_process_environment, +) from .manifest import validate_extension_id @@ -76,10 +80,6 @@ "secret", "token", } -_CORE_VIEW_VALIDATORS = { - "loopx.extensions.presentation:validate_opaque_presentation_view", -} - _ISOLATED_VIEW_VALIDATOR = """\ import importlib import json @@ -334,7 +334,7 @@ def load_presentation_view_validator( if not isinstance(reference, str) or ":" not in reference: raise ValueError("presentation surface has no declared view_validator") module_name, attribute_name = reference.split(":", 1) - if runtime_entrypoint is not None and reference not in _CORE_VIEW_VALIDATORS: + if runtime_entrypoint is not None and reference not in CORE_VIEW_VALIDATORS: python_executable = runtime_entrypoint.python_executable if python_executable is None: raise ValueError( diff --git a/loopx/extensions/readiness.py b/loopx/extensions/readiness.py index b2b053f60..0383dbeed 100644 --- a/loopx/extensions/readiness.py +++ b/loopx/extensions/readiness.py @@ -10,11 +10,20 @@ import sys from collections.abc import Mapping from dataclasses import dataclass +from functools import lru_cache from pathlib import Path from typing import Any EXTENSION_DOCTOR_SCHEMA_VERSION = "loopx_extension_doctor_v0" RUNTIME_ENTRYPOINT_IDENTITY_SCHEMA_VERSION = "loopx_runtime_entrypoint_identity_v1" +RUNTIME_EXECUTABLE_IDENTITY_SCHEMA_VERSION = "loopx_runtime_executable_identity_v1" + +# LoopX-owned validators execute in the LoopX process and are not extension +# artifacts. Binding them would invalidate every installed extension whenever +# LoopX itself is upgraded. +CORE_VIEW_VALIDATORS = frozenset( + {"loopx.extensions.presentation:validate_opaque_presentation_view"} +) @dataclass(frozen=True) @@ -41,17 +50,17 @@ def _python_executable_for_script(path: Path) -> str | None: if command: executable = command[0] if Path(executable).name == "env": - candidates = [ + command_candidates = [ item for item in command[1:] if not item.startswith("-") ] executable = ( shutil.which( - candidates[0], + command_candidates[0], path=str(path.parent) + os.pathsep + os.environ.get("PATH", os.defpath), ) - if candidates + if command_candidates else None ) or "" selected = Path(executable).expanduser() @@ -140,19 +149,173 @@ def resolved_entrypoint_identity(command: str) -> tuple[Path, str] | None: return path, identified[1] +_RESOLVE_DECLARED_MODULE_ORIGIN = """\ +import importlib.util +import json +import sys + +try: + spec = importlib.util.find_spec(sys.argv[1]) +except Exception: + spec = None +origin = getattr(spec, "origin", None) if spec is not None else None +json.dump({"origin": origin if isinstance(origin, str) else None}, sys.stdout) +""" + + +def declared_view_validators(manifest: Mapping[str, Any]) -> tuple[str, ...]: + """List the extension-owned validator references one manifest declares.""" + + references = { + str(surface["view_validator"]) + for surface in manifest.get("presentation_surfaces") or [] + if isinstance(surface, Mapping) + and isinstance(surface.get("view_validator"), str) + and str(surface["view_validator"]) not in CORE_VIEW_VALIDATORS + } + return tuple(sorted(references)) + + +@lru_cache(maxsize=64) +def _declared_module_origin( + python_executable: str, + module_name: str, +) -> str | None: + """Resolve one declared module's source file in the runtime interpreter. + + The isolated validator imports this module in a ``python -I`` process, so + the identity owner asks that same interpreter where the implementation lives + instead of guessing a site-packages layout or reusing LoopX's own import + state. Only the resolved path is memoized; the artifact bytes are re-read on + every identity computation so a content mutation still changes the identity. + """ + + try: + completed = subprocess.run( + [ + python_executable, + "-I", + "-c", + _RESOLVE_DECLARED_MODULE_ORIGIN, + module_name, + ], + stdin=subprocess.DEVNULL, + capture_output=True, + timeout=30, + check=False, + text=True, + encoding="utf-8", + ) + except (OSError, subprocess.TimeoutExpired): + return None + if completed.returncode != 0: + return None + try: + payload = json.loads(completed.stdout) + except json.JSONDecodeError: + return None + origin = payload.get("origin") if isinstance(payload, Mapping) else None + return origin if isinstance(origin, str) and origin else None + + +def _declared_validator_artifacts( + python_executable: str, + view_validators: tuple[str, ...], +) -> dict[str, str | None]: + """Bind every declared validator implementation the runtime can resolve. + + A validator that resolves has its implementation bytes bound, so replacing + the decision code invalidates the doctor identity even when the launcher, + the interpreter and the declared reference are unchanged. A validator the + runtime interpreter cannot resolve cannot execute either - the isolated + runner resolves the same module with the same interpreter and flags - so its + marker records that state instead of refusing the provider runtime + readiness. Resolving the module file deliberately does not import it: an + implementation that resolves but raises on import is an execution failure at + the surface that uses it, not a provider-runtime identity change. + """ + + artifacts: dict[str, str | None] = {} + for reference in view_validators: + module_name = reference.split(":", 1)[0] + origin = _declared_module_origin(python_executable, module_name) + artifact = ( + None + if origin is None + else _file_identity(Path(origin), executable=False) + ) + artifacts[module_name] = None if artifact is None else artifact[1] + return artifacts + + +def _runtime_executable_identity( + entrypoint_identity: str, + python_executable: str | None, + *, + view_validators: tuple[str, ...] = (), +) -> str | None: + """Bind every executable artifact one runtime selects for its provider. + + A runtime that declares no extension-owned validator keeps the identity of + its launcher and selected interpreter alone, so an extension whose code did + not change does not have to be re-doctored. A runtime that does declare one + binds the implementation each reference resolves to, because that code runs + in the runtime interpreter and outside LoopX's own verified launcher. + """ + + if python_executable is None and not view_validators: + return entrypoint_identity + interpreter_identity: str | None = None + if python_executable is not None: + interpreter = _file_identity(Path(python_executable), executable=True) + if interpreter is None: + return None + interpreter_identity = interpreter[1] + identity_payload: dict[str, Any] = { + "schema_version": RUNTIME_EXECUTABLE_IDENTITY_SCHEMA_VERSION, + "kind": "executable_runtime", + "entrypoint_identity": entrypoint_identity, + "python_interpreter_identity": interpreter_identity, + } + if view_validators: + identity_payload["view_validator_artifacts"] = ( + _declared_validator_artifacts(python_executable, view_validators) + if python_executable is not None + # No interpreter means no isolated validator can run; record the + # declared references so adding one later still changes the identity. + else {reference.split(":", 1)[0]: None for reference in view_validators} + ) + serialized = json.dumps( + identity_payload, + sort_keys=True, + separators=(",", ":"), + ) + return hashlib.sha256(serialized.encode("utf-8")).hexdigest() + + def resolve_runtime_entrypoint( runtime: Mapping[str, Any], + *, + view_validators: tuple[str, ...] = (), ) -> ResolvedRuntimeEntrypoint | None: python_module = runtime.get("python_module") if python_module is None: resolved = resolved_entrypoint_identity(str(runtime["entrypoint"])) if resolved is None: return None + python_executable = _python_executable_for_script(resolved[0]) + identity = _runtime_executable_identity( + resolved[1], + python_executable, + view_validators=view_validators, + ) + if identity is None: + return None return ResolvedRuntimeEntrypoint( argv_prefix=(str(resolved[0]),), - identity=resolved[1], + identity=identity, path_prefix=str(resolved[0].parent), - python_executable=_python_executable_for_script(resolved[0]), + python_executable=python_executable, ) interpreter_path = Path(sys.executable).expanduser() @@ -172,6 +335,11 @@ def resolve_runtime_entrypoint( "module": str(python_module), "module_identity": module[1], } + if view_validators: + identity_payload["view_validator_artifacts"] = _declared_validator_artifacts( + str(interpreter_path), + view_validators, + ) serialized = json.dumps( identity_payload, sort_keys=True, @@ -190,7 +358,11 @@ def extension_doctor( execute: bool = False, ) -> dict[str, Any]: runtime = extension_runtime(manifest) - identity_before = resolve_runtime_entrypoint(runtime) + view_validators = declared_view_validators(manifest) + identity_before = resolve_runtime_entrypoint( + runtime, + view_validators=view_validators, + ) available = identity_before is not None doctor_args = [str(value) for value in runtime.get("doctor_args") or []] status = "ready" if available else "entrypoint_missing" @@ -227,7 +399,10 @@ def extension_doctor( available = False failure_kind = failure_kind or "probe_nonzero_exit" else: - identity_after = resolve_runtime_entrypoint(runtime) + identity_after = resolve_runtime_entrypoint( + runtime, + view_validators=view_validators, + ) if ( identity_after is None or identity_after.identity != identity_before.identity diff --git a/loopx/extensions/runtime.py b/loopx/extensions/runtime.py index 60c4424b1..b5d2ac405 100644 --- a/loopx/extensions/runtime.py +++ b/loopx/extensions/runtime.py @@ -18,6 +18,7 @@ from .readiness import ( EXTENSION_DOCTOR_SCHEMA_VERSION, ResolvedRuntimeEntrypoint, + declared_view_validators, extension_runtime, resolve_runtime_entrypoint, runtime_process_environment, @@ -561,7 +562,10 @@ def _verified_entrypoint( if not isinstance(manifest, Mapping): return None runtime = located_runtime(manifest, snapshot.get("entrypoint_path")) - identity = resolve_runtime_entrypoint(runtime) + identity = resolve_runtime_entrypoint( + runtime, + view_validators=declared_view_validators(manifest), + ) if identity is None or identity.identity != entry.get( "doctor_verified_entrypoint_identity" ): diff --git a/tests/extensions/test_extension_presentation.py b/tests/extensions/test_extension_presentation.py index 2f62cee29..98fcd30ec 100644 --- a/tests/extensions/test_extension_presentation.py +++ b/tests/extensions/test_extension_presentation.py @@ -156,6 +156,26 @@ def _installed_projection_extension( return state_file, installed +def _set_view_validator(state_file: Path, reference: str) -> None: + """Point the installed surface at another declared validator.""" + + state = json.loads(state_file.read_text(encoding="utf-8")) + surface = state["extensions"]["test-research-extension"]["revisions"][0][ + "manifest" + ]["presentation_surfaces"][0] + surface["view_validator"] = reference + state_file.write_text(json.dumps(state), encoding="utf-8") + + +def _verify_installed_extension(state_file: Path) -> None: + doctor = doctor_installed_extension( + "test-research-extension", + state_file=state_file, + execute=True, + ) + assert doctor["verified"] + + def test_projection_publication_dry_run_does_not_invoke_or_write( tmp_path: Path, ) -> None: @@ -311,12 +331,10 @@ def test_projection_publication_dry_run_skips_validator_loading( tmp_path, invocation_marker=marker, ) - state = json.loads(state_file.read_text(encoding="utf-8")) - surface = state["extensions"]["test-research-extension"]["revisions"][0][ - "manifest" - ]["presentation_surfaces"][0] - surface["view_validator"] = "missing_validator_module:validate_view" - state_file.write_text(json.dumps(state), encoding="utf-8") + _set_view_validator(state_file, "missing_validator_module:validate_view") + # The declaration is part of the verified runtime identity, so re-running the + # doctor is what lets this surface reach its own validator error. + _verify_installed_extension(state_file) receipt = publish_extension_projection( "test-research-extension", @@ -339,12 +357,10 @@ def test_projection_publication_fails_when_validator_unavailable_on_execute( tmp_path, invocation_marker=marker, ) - state = json.loads(state_file.read_text(encoding="utf-8")) - surface = state["extensions"]["test-research-extension"]["revisions"][0][ - "manifest" - ]["presentation_surfaces"][0] - surface["view_validator"] = "missing_validator_module:validate_view" - state_file.write_text(json.dumps(state), encoding="utf-8") + _set_view_validator(state_file, "missing_validator_module:validate_view") + # The declaration is part of the verified runtime identity, so re-running the + # doctor is what lets this surface reach its own validator error. + _verify_installed_extension(state_file) with pytest.raises(ValueError, match="view_validator .* is unavailable"): publish_extension_projection( @@ -358,6 +374,31 @@ def test_projection_publication_fails_when_validator_unavailable_on_execute( assert not marker.exists() +def test_projection_publication_requires_fresh_doctor_after_declaration_change( + tmp_path: Path, +) -> None: + marker = tmp_path / "provider-called" + state_file, _ = _installed_projection_extension( + tmp_path, + invocation_marker=marker, + ) + _set_view_validator(state_file, "missing_validator_module:validate_view") + + # The runtime's verified executable contract changed: the declaration now + # names a different implementation, so the old doctor proof cannot be reused + # and nothing may execute against the unverified declaration. + with pytest.raises(ValueError, match="doctor readiness is stale"): + publish_extension_projection( + "test-research-extension", + "investment-research", + state_file=state_file, + request={"schema_version": "synthetic_request_v0"}, + execute=True, + ) + + assert not marker.exists() + + @pytest.mark.skipif(os.name == "nt", reason="POSIX console-script shebang fixture") def test_projection_publication_loads_validator_from_isolated_runtime( tmp_path: Path, diff --git a/tests/extensions/test_extension_runtime.py b/tests/extensions/test_extension_runtime.py index d5198cd42..dbf7a16b5 100644 --- a/tests/extensions/test_extension_runtime.py +++ b/tests/extensions/test_extension_runtime.py @@ -40,6 +40,7 @@ doctor_installed_extension, enable_extension, execute_extension_runtime_binding, + extension_catalog_entries, extension_status, install_extension, resolve_capability_binding, @@ -690,6 +691,162 @@ def test_runtime_entrypoint_resolves_sibling_python_for_opaque_launcher( assert resolved.python_executable == str(sibling_python) +@pytest.mark.skipif(os.name == "nt", reason="POSIX symlink mutation fixture") +def test_opaque_runtime_identity_binds_sibling_python_artifact( + tmp_path: Path, +) -> None: + launcher = tmp_path / "provider.exe" + launcher.write_bytes(b"MZ opaque console launcher") + launcher.chmod(0o755) + sibling_python = tmp_path / "python.exe" + sibling_python.symlink_to(sys.executable) + + verified = resolve_runtime_entrypoint({"entrypoint": str(launcher)}) + sibling_python.unlink() + sibling_python.symlink_to("/bin/sh") + changed = resolve_runtime_entrypoint({"entrypoint": str(launcher)}) + + assert verified is not None and changed is not None + assert changed.python_executable == verified.python_executable == str(sibling_python) + assert changed.identity != verified.identity + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX symlink mutation fixture") +def test_runtime_entrypoint_identity_binds_selected_python_artifact( + tmp_path: Path, +) -> None: + runtime_root = tmp_path / "runtime" + runtime_root.mkdir() + interpreter = runtime_root / "python" + interpreter.symlink_to(sys.executable) + provider = runtime_root / "provider" + provider.write_text( + f"#!{interpreter}\nraise SystemExit(0)\n", + encoding="utf-8", + ) + provider.chmod(0o755) + + verified = resolve_runtime_entrypoint({"entrypoint": str(provider)}) + interpreter.unlink() + interpreter.symlink_to("/bin/sh") + changed = resolve_runtime_entrypoint({"entrypoint": str(provider)}) + + assert verified is not None and changed is not None + assert changed.python_executable == verified.python_executable == str(interpreter) + assert changed.identity != verified.identity + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX shebang lifecycle fixture") +def test_catalog_invalidates_doctor_when_selected_python_artifact_changes( + tmp_path: Path, +) -> None: + runtime_root = tmp_path / "runtime" + runtime_root.mkdir() + interpreter = runtime_root / "python" + interpreter.symlink_to(sys.executable) + provider = _provider(runtime_root / "provider") + provider.write_text( + provider.read_text(encoding="utf-8").replace( + f"#!{sys.executable}", + f"#!{interpreter}", + 1, + ), + encoding="utf-8", + ) + manifest = _manifest( + tmp_path / "extension.toml", + entrypoint=provider, + version="1.0.0", + ) + state_file = tmp_path / "extensions.json" + install_extension(manifest, state_file=state_file, execute=True) + ready = extension_catalog_entries(state_file=state_file) + assert ready[0]["provider"]["ready"] is True + + interpreter.unlink() + interpreter.symlink_to("/bin/sh") + + stale = extension_catalog_entries(state_file=state_file) + assert stale[0]["provider"]["ready"] is False + with pytest.raises(ValueError, match="doctor readiness is stale"): + resolve_extension_binding( + "test-semantic-extension", + state_file=state_file, + capability_id="semantic-preference", + protocol="semantic_preference_provider_v0", + permission="semantic_preference.read", + ) + + +def test_runtime_identity_binds_declared_view_validator_reference( + tmp_path: Path, +) -> None: + provider = _provider(tmp_path / "provider") + manifest = load_extension_manifest( + _standalone_manifest(tmp_path / "extension.toml", entrypoint=provider) + ) + runtime = manifest["runtime"] + declaration = ("missing_validator_module:validate_view",) + + undeclared = resolve_runtime_entrypoint(runtime) + declared = resolve_runtime_entrypoint(runtime, view_validators=declaration) + repeated = resolve_runtime_entrypoint(runtime, view_validators=declaration) + + assert undeclared is not None and declared is not None + assert repeated is not None + # Declaring implementation code the runtime interpreter runs is part of what + # the doctor verifies, and an unresolvable declaration must not drift between + # two computations of the same declaration. + assert declared.identity != undeclared.identity + assert repeated.identity == declared.identity + + +def test_doctor_keeps_provider_ready_when_declared_validator_cannot_resolve( + tmp_path: Path, +) -> None: + """A validator the runtime cannot import is not a provider-runtime fault. + + The doctor owns the provider runtime: the launcher, the selected interpreter + and the implementations that run in it. A declared validator the runtime + interpreter cannot resolve is recorded in that identity, and the projection + surface that uses it fails with its own actionable error instead. + """ + + provider = _provider(tmp_path / "provider") + manifest = _presentation_surface_manifest( + tmp_path / "extension.toml", + entrypoint=provider, + declaration=""" +[[presentation_surfaces]] +id = "investment-research" +kind = "decision_research_dashboard" +title = "Investment Research" +view_schema = "decision_research_dashboard_v0" +view_validator = "missing_validator_module:validate_view" +visibility = "owner-only" +empty_state_title = "No validated research yet" +empty_state_detail = "Publish a validated projection." +""", + include_view_validator=False, + ) + state_file = tmp_path / "extensions.json" + installed = install_extension(manifest, state_file=state_file, execute=True) + + assert installed["doctor"]["verified"] is True + assert extension_catalog_entries(state_file=state_file)[0]["provider"]["ready"] + + redoctored = doctor_installed_extension( + "test-standalone-extension", + state_file=state_file, + execute=True, + ) + assert redoctored["verified"] is True + assert ( + redoctored["entrypoint_identity"] + == installed["doctor"]["entrypoint_identity"] + ) + + def test_standalone_runtime_does_not_require_a_capability_contract( tmp_path: Path, ) -> None: diff --git a/tests/extensions/test_finance_value_discovery_extension.py b/tests/extensions/test_finance_value_discovery_extension.py index d76d3c67f..52eb5d4d2 100644 --- a/tests/extensions/test_finance_value_discovery_extension.py +++ b/tests/extensions/test_finance_value_discovery_extension.py @@ -3,6 +3,7 @@ import io import json import os +import shutil import subprocess import sys import tomllib @@ -15,9 +16,14 @@ from loopx.capabilities.catalog import build_capability_catalog_packet from loopx.cli import main from loopx.extensions.manifest import load_extension_manifest -from loopx.extensions.presentation import publish_extension_projection +from loopx.extensions.presentation import ( + default_extension_projection_root, + publish_extension_projection, +) from loopx.extensions.runtime import ( default_extension_state_file, + doctor_installed_extension, + extension_catalog_entries, install_extension, ) @@ -803,6 +809,120 @@ def test_projection_publisher_loads_finance_validator_from_manifest( ) +@pytest.mark.skipif(os.name == "nt", reason="POSIX console-script shebang fixture") +def test_doctor_identity_binds_declared_validator_implementation( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A replaced validator implementation cannot reuse the earlier doctor proof.""" + + runtime_source = tmp_path / "finance-source" + shutil.copytree(EXTENSION_SRC, runtime_source) + runtime_root = tmp_path / "finance-runtime" + venv.EnvBuilder(with_pip=False, symlinks=True).create(runtime_root) + runtime_python = runtime_root / "bin" / "python" + purelib = subprocess.run( + [ + str(runtime_python), + "-I", + "-c", + "import sysconfig; print(sysconfig.get_paths()['purelib'])", + ], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + (Path(purelib) / "finance-value-discovery-source.pth").write_text( + str(runtime_source) + "\n", + encoding="utf-8", + ) + unacceptable_view = "must be accepted only by the replaced decision code" + packet = build_finance_research_dashboard_packet(_research_dashboard_input()) + packet["presentation_projection"]["view"]["unsupported"] = unacceptable_view + invocation_marker = tmp_path / "provider-called" + provider = runtime_root / "bin" / "finance-provider" + provider.write_text( + f"""#!{runtime_python} +import json +import sys +from pathlib import Path + +if "--doctor" in sys.argv: + raise SystemExit(0) + +Path({str(invocation_marker)!r}).write_text("called", encoding="utf-8") +json.load(sys.stdin) +json.dump({packet!r}, sys.stdout) +""", + encoding="utf-8", + ) + provider.chmod(0o755) + manifest = tmp_path / "extension.toml" + manifest.write_text( + MANIFEST.read_text(encoding="utf-8").replace( + 'entrypoint = "loopx-finance-value-discovery"', + f"entrypoint = {json.dumps(str(provider))}", + ), + encoding="utf-8", + ) + monkeypatch.delenv("PYTHONPATH", raising=False) + state_file = default_extension_state_file(tmp_path / "runtime") + install_extension(manifest, state_file=state_file, execute=True) + + def publish() -> dict[str, object]: + return publish_extension_projection( + "loopx-finance-value-discovery", + "investment-research", + state_file=state_file, + request=_research_dashboard_input(), + execute=True, + ) + + def ready() -> bool: + entries = extension_catalog_entries([manifest], state_file=state_file) + return bool(entries[0]["provider"]["ready"]) + + assert ready() is True + with pytest.raises(ValueError, match="unsupported keys"): + publish() + invocation_marker.unlink() + + validator_module = ( + runtime_source / "loopx_finance_value_discovery" / "presentation_view.py" + ) + validator_module.write_text( + validator_module.read_text(encoding="utf-8") + + "\n\ndef validate_decision_research_view(view):\n" + ' """Replacement implementation that accepts any view structure."""\n' + " return view\n", + encoding="utf-8", + ) + + assert ready() is False + with pytest.raises(ValueError, match="doctor readiness is stale"): + publish() + assert not invocation_marker.exists() + + doctor = doctor_installed_extension( + "loopx-finance-value-discovery", + state_file=state_file, + execute=True, + ) + assert doctor["verified"] is True + assert ready() is True + + # Re-verifying the replaced implementation makes it runnable again, and the + # published view proves the replaced decision code is what executed. + assert publish()["status"] == "published" + projection_file = ( + default_extension_projection_root(state_file) + / "loopx-finance-value-discovery" + / "investment-research.json" + ) + persisted = json.loads(projection_file.read_text(encoding="utf-8")) + assert persisted["view"]["unsupported"] == unacceptable_view + + @pytest.mark.parametrize("legacy_command", ["source-map", "install-check"]) def test_legacy_connector_returns_extension_migration_packet( legacy_command: str,