diff --git a/backend/cortex_backend/execution/__init__.py b/backend/cortex_backend/execution/__init__.py index 3171c98..577e7ff 100644 --- a/backend/cortex_backend/execution/__init__.py +++ b/backend/cortex_backend/execution/__init__.py @@ -67,6 +67,16 @@ NativeBrokerServerConfig, build_pipe_sddl, ) +from .native_launcher import ( + BrokerWorkerBinding, + BrokerWorkerBinder, + NativeLauncherError, + NativeProcessFactory, + NativeSuspendedWorker, + NativeWorkerLaunchPlan, + NativeWorkerLauncher, + NativeWorkerPolicy, +) from .worker_provenance import ( EXPECTED_WORKER_PATH, VerifiedRecipeWorker, @@ -137,6 +147,14 @@ "NativeBrokerError", "NativeBrokerServer", "NativeBrokerServerConfig", + "BrokerWorkerBinding", + "BrokerWorkerBinder", + "NativeLauncherError", + "NativeProcessFactory", + "NativeSuspendedWorker", + "NativeWorkerLaunchPlan", + "NativeWorkerLauncher", + "NativeWorkerPolicy", "PrimitiveEvaluationError", "OutputClaim", "PublishedArtifact", diff --git a/backend/cortex_backend/execution/native_launcher.py b/backend/cortex_backend/execution/native_launcher.py new file mode 100644 index 0000000..6d9f668 --- /dev/null +++ b/backend/cortex_backend/execution/native_launcher.py @@ -0,0 +1,327 @@ +"""Fail-closed native worker launch boundary. + +This module is the seam between storage-only worker provenance and the future +Windows process factory. It deliberately does not execute through +``subprocess`` or provide a host-process fallback. A reviewed native factory and +a live broker binder must be injected before a process can be created. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from hashlib import sha256 +import os +from pathlib import Path +import re +import subprocess +from typing import Final, Protocol + +from .bundle_installer import SignedBundleInstaller +from .worker_provenance import ( + EXPECTED_WORKER_PATH, + VerifiedRecipeWorker, + WorkerProvenanceError, + verify_active_worker, +) + + +_SAFE_CODE = re.compile(r"^[a-z][a-z0-9_]{0,63}$") +_SAFE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$") +_PRINCIPAL = re.compile(r"^[0-9a-f]{64}$") +_PIPE = re.compile(r"^\\\\\.\\pipe\\cortex-[A-Za-z0-9._-]{1,200}$") +_MAX_WORKER_BYTES = 128 * 1024 * 1024 +_JOB_OBJECT_LIMIT_PROCESS_TIME = 0x00000002 +_JOB_OBJECT_LIMIT_JOB_TIME = 0x00000004 +_JOB_OBJECT_LIMIT_ACTIVE_PROCESS = 0x00000008 +_JOB_OBJECT_LIMIT_PROCESS_MEMORY = 0x00000100 +_JOB_OBJECT_LIMIT_JOB_MEMORY = 0x00000200 +_JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000 +_REQUIRED_LIMIT_FLAGS: Final[int] = ( + _JOB_OBJECT_LIMIT_PROCESS_TIME + | _JOB_OBJECT_LIMIT_JOB_TIME + | _JOB_OBJECT_LIMIT_ACTIVE_PROCESS + | _JOB_OBJECT_LIMIT_PROCESS_MEMORY + | _JOB_OBJECT_LIMIT_JOB_MEMORY + | _JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE +) + + +class NativeLauncherError(ValueError): + """Stable, non-sensitive native launch failure category.""" + + def __init__(self, code: str) -> None: + if _SAFE_CODE.fullmatch(code) is None: + raise ValueError("invalid native launcher code") + self.code = code + super().__init__("The recipe worker could not be launched safely.") + + +def _positive_bounded(value: int, *, maximum: int, code: str) -> int: + if type(value) is not int or not 1 <= value <= maximum: + raise ValueError(code) + return value + + +@dataclass(frozen=True, slots=True) +class NativeWorkerPolicy: + """Resource policy that must be applied before a suspended worker resumes.""" + + active_process_limit: int = 1 + process_memory_limit_bytes: int = 64 * 1024 * 1024 + job_memory_limit_bytes: int = 128 * 1024 * 1024 + process_user_time_100ns: int = 20_000_000 + job_user_time_100ns: int = 40_000_000 + watchdog_timeout_ms: int = 30_000 + + def __post_init__(self) -> None: + _positive_bounded(self.active_process_limit, maximum=64, code="active_process_limit_invalid") + _positive_bounded( + self.process_memory_limit_bytes, + maximum=1024 * 1024 * 1024, + code="process_memory_limit_invalid", + ) + _positive_bounded( + self.job_memory_limit_bytes, + maximum=1024 * 1024 * 1024, + code="job_memory_limit_invalid", + ) + _positive_bounded( + self.process_user_time_100ns, + maximum=600 * 10_000_000, + code="process_cpu_limit_invalid", + ) + _positive_bounded( + self.job_user_time_100ns, + maximum=600 * 10_000_000, + code="job_cpu_limit_invalid", + ) + _positive_bounded(self.watchdog_timeout_ms, maximum=600_000, code="watchdog_timeout_invalid") + if self.job_memory_limit_bytes < self.process_memory_limit_bytes: + raise ValueError("job memory limit must cover the process limit") + if self.job_user_time_100ns < self.process_user_time_100ns: + raise ValueError("job CPU limit must cover the process limit") + + @property + def required_limit_flags(self) -> int: + """Return the exact flags; breakaway flags are intentionally absent.""" + + return _REQUIRED_LIMIT_FLAGS + + +@dataclass(frozen=True, slots=True) +class BrokerWorkerBinding: + """Trusted broker identity values used to bind one suspended worker.""" + + pipe_name: str + broker_process_id: int + installation_principal_id: str + job_id: str + + def __post_init__(self) -> None: + if _PIPE.fullmatch(self.pipe_name) is None: + raise ValueError("broker pipe name is invalid") + if type(self.broker_process_id) is not int or self.broker_process_id <= 0: + raise ValueError("broker process ID is invalid") + if _PRINCIPAL.fullmatch(self.installation_principal_id) is None: + raise ValueError("installation principal is invalid") + if _SAFE_ID.fullmatch(self.job_id) is None: + raise ValueError("job ID is invalid") + + +@dataclass(frozen=True, slots=True) +class NativeWorkerLaunchPlan: + """Immutable launch inputs after provenance and command-line validation.""" + + worker: VerifiedRecipeWorker + executable: Path + command_line: str + broker: BrokerWorkerBinding + policy: NativeWorkerPolicy + + +class NativeSuspendedWorker(Protocol): + """Handle returned by a reviewed Windows native process factory.""" + + process_id: int + app_container_sid: str + + def apply_job_policy(self, policy: NativeWorkerPolicy) -> None: + """Assign the suspended process and verify all Job Object limits.""" + + def resume(self) -> None: + """Resume only after policy and broker binding have succeeded.""" + + def close(self) -> None: + """Kill-on-close and release every native handle.""" + + +class NativeProcessFactory(Protocol): + """Factory implemented only by the reviewed Windows ctypes adapter.""" + + def create_suspended(self, plan: NativeWorkerLaunchPlan) -> NativeSuspendedWorker: + """Create the verified worker with its AppContainer token suspended.""" + + +class BrokerWorkerBinder(Protocol): + """Live native broker binding performed before the worker resumes.""" + + def bind_worker( + self, + *, + process_id: int, + app_container_sid: str, + binding: BrokerWorkerBinding, + ) -> None: + """Bind expected PID, token identity, principal, and job ownership.""" + + +def _revalidate_worker(worker: VerifiedRecipeWorker) -> Path: + root = worker.bundle_root + try: + root = root.resolve(strict=True) + raw_candidate = root / worker.worker_path + if raw_candidate.is_symlink() or getattr(raw_candidate, "is_junction", lambda: False)(): + raise NativeLauncherError("worker_path_invalid") + candidate = raw_candidate.resolve(strict=True) + except (OSError, RuntimeError): + raise NativeLauncherError("worker_path_unavailable") from None + if worker.worker_path != EXPECTED_WORKER_PATH or not candidate.is_relative_to(root): + raise NativeLauncherError("worker_path_invalid") + try: + stat = candidate.stat() + if not candidate.is_file() or candidate.is_symlink() or stat.st_nlink != 1: + raise NativeLauncherError("worker_path_invalid") + if stat.st_size != worker.worker_size or stat.st_size > _MAX_WORKER_BYTES: + raise NativeLauncherError("worker_size_mismatch") + before_identity = ( + int(stat.st_size), + int(stat.st_mtime_ns), + int(stat.st_ctime_ns), + int(getattr(stat, "st_ino", 0)), + ) + digest = sha256() + with candidate.open("rb") as stream: + remaining = worker.worker_size + while remaining: + chunk = stream.read(min(1024 * 1024, remaining)) + if not chunk: + raise NativeLauncherError("worker_size_mismatch") + digest.update(chunk) + remaining -= len(chunk) + after = candidate.stat() + except NativeLauncherError: + raise + except OSError: + raise NativeLauncherError("worker_path_unavailable") from None + after_identity = ( + int(after.st_size), + int(after.st_mtime_ns), + int(after.st_ctime_ns), + int(getattr(after, "st_ino", 0)), + ) + if before_identity != after_identity: + raise NativeLauncherError("worker_path_changed") + if digest.hexdigest() != worker.worker_sha256: + raise NativeLauncherError("worker_hash_mismatch") + return candidate + + +class NativeWorkerLauncher: + """Construct and execute only a verified, broker-bound native worker.""" + + def __init__( + self, + installer: SignedBundleInstaller, + *, + process_factory: NativeProcessFactory | None = None, + broker_binder: BrokerWorkerBinder | None = None, + ) -> None: + if not isinstance(installer, SignedBundleInstaller): + raise TypeError("installer must be a SignedBundleInstaller") + self._installer = installer + self._process_factory = process_factory + self._broker_binder = broker_binder + + def prepare( + self, + binding: BrokerWorkerBinding, + policy: NativeWorkerPolicy | None = None, + ) -> NativeWorkerLaunchPlan: + """Reverify the active bundle and build a fixed command line only.""" + + if not isinstance(binding, BrokerWorkerBinding): + raise TypeError("binding must be a BrokerWorkerBinding") + if policy is not None and not isinstance(policy, NativeWorkerPolicy): + raise TypeError("policy must be a NativeWorkerPolicy") + try: + worker = verify_active_worker(self._installer) + except WorkerProvenanceError as error: + raise NativeLauncherError(error.code) from None + executable = _revalidate_worker(worker) + command_line = subprocess.list2cmdline( + [ + str(executable), + "--native-broker", + "--broker-pipe", + binding.pipe_name, + "--broker-pid", + str(binding.broker_process_id), + ] + ) + return NativeWorkerLaunchPlan( + worker=worker, + executable=executable, + command_line=command_line, + broker=binding, + policy=policy or NativeWorkerPolicy(), + ) + + def launch( + self, + binding: BrokerWorkerBinding, + policy: NativeWorkerPolicy | None = None, + ) -> NativeSuspendedWorker: + """Launch only through injected native and broker-reviewed adapters.""" + + if os.name != "nt": + raise NativeLauncherError("native_windows_required") + if self._broker_binder is None: + raise NativeLauncherError("native_broker_binding_required") + if self._process_factory is None: + raise NativeLauncherError("native_process_factory_required") + plan = self.prepare(binding, policy) + worker: NativeSuspendedWorker | None = None + try: + worker = self._process_factory.create_suspended(plan) + if type(worker.process_id) is not int or worker.process_id <= 0: + raise NativeLauncherError("native_worker_identity_invalid") + if not isinstance(worker.app_container_sid, str) or not worker.app_container_sid: + raise NativeLauncherError("native_worker_identity_invalid") + worker.apply_job_policy(plan.policy) + self._broker_binder.bind_worker( + process_id=worker.process_id, + app_container_sid=worker.app_container_sid, + binding=plan.broker, + ) + worker.resume() + return worker + except NativeLauncherError: + if worker is not None: + worker.close() + raise + except Exception: + if worker is not None: + worker.close() + raise NativeLauncherError("native_launch_failed") from None + + +__all__ = [ + "BrokerWorkerBinding", + "BrokerWorkerBinder", + "NativeLauncherError", + "NativeProcessFactory", + "NativeSuspendedWorker", + "NativeWorkerLaunchPlan", + "NativeWorkerLauncher", + "NativeWorkerPolicy", +] diff --git a/docs/adr/0001-phase2-evidence.md b/docs/adr/0001-phase2-evidence.md index 8ec1398..2cf1269 100644 --- a/docs/adr/0001-phase2-evidence.md +++ b/docs/adr/0001-phase2-evidence.md @@ -24,8 +24,8 @@ | User-artifact copy-in, output validation, and publication | **Complete (boundary only)** | Explicit owner/turn grants, bounded stable snapshots, link/reparse/hardlink/sparse/ADS rejection, byte-derived MIME policy, exact output claims, quarantine, hash/size limits, atomic repository publication, rollback, and cleanup categories are covered by `tests/test_phase2_artifact_boundary.py`. | | Fixed-function image provider core | **Complete (qualification-only)** | `RecipeImageProvider` validates allowlisted PNG/JPEG/WebP bytes, verifies/loads one frame with Pillow bomb/resource limits, applies only parsed steps, strips metadata, revalidates encoded output, checks cancellation, and remains disabled until external sandbox health passes. | | Windows recipe sandbox qualification harness | **Complete (qualification harness; worker gate blocked)** | `recipe_sandbox_qualification.py` composes out-of-process AppContainer isolation and Job Object cancellation with a fixed decoder corpus, then fails closed because the signed `recipe_worker.exe` bundle and trust-root launch verification are not shipped. | -| Suspended native launcher/resource policy | **Complete (disposable control spike)** | `native_launcher_qualification.py` creates a fixed suspended AppContainer child, applies and queries active-process, kill-on-close, CPU-time, memory, and no-breakaway Job Object policy before resume; real worker enforcement and broker binding remain blocked. | -| OS sandbox provider and provider-produced image outputs | **Blocked / release gate** | The package/protocol boundary is qualified, but the actual provider worker still needs a signed installed generation, native broker loop, LPAC/AppContainer policy, Job Object resource limits/accounting, live broker PID/token binding, watchdog, hostile decoder execution inside the sandbox, external review, and lifecycle wiring. | +| Suspended native launcher/resource policy | **Complete (boundary + disposable control spike)** | `NativeWorkerLauncher` revalidates the signed worker, fixes the broker-only command line, requires both native factory and broker binder, and enforces policy-before-bind-before-resume. `native_launcher_qualification.py` separately proves the queried Job Object policy with a fixed suspended child; concrete worker enforcement and live broker binding remain blocked. | +| OS sandbox provider and provider-produced image outputs | **Blocked / release gate** | The package/protocol/launch boundary is qualified, but the actual provider worker still needs a signed installed generation, concrete Win32 process factory, native broker loop, LPAC/AppContainer policy, Job Object resource limits/accounting, live broker PID/token binding, watchdog, hostile decoder execution inside the sandbox, external review, and lifecycle wiring. | ## Security invariants @@ -114,11 +114,11 @@ npm.cmd test --prefix frontend -- --run **Validation result (2026-07-23):** 16 Phase 2 contract tests, 9 signed-manifest tests, 7 broker-contract tests, 9 native-broker tests, 7 bundle-installer tests, 16 artifact-boundary tests, 17 recipe-provider tests, 6 worker-provenance tests, 7 -worker-protocol tests, 4 +worker-protocol tests, 11 native-launcher-boundary tests, 4 native-launcher tests, and 5 sandbox-qualification tests passed; the full Python suite -passed (226 tests total) with one +passed (237 tests total) with one native-platform skip and one pre-existing `pytest-asyncio` deprecation warning. Frontend lint, typecheck, production build, and all 39 frontend tests passed. Contract generation, compileall, and `git diff --check` passed. No production execution diff --git a/docs/adr/0001-phase2-native-launcher.md b/docs/adr/0001-phase2-native-launcher.md index c857c52..1dae651 100644 --- a/docs/adr/0001-phase2-native-launcher.md +++ b/docs/adr/0001-phase2-native-launcher.md @@ -1,6 +1,6 @@ # ADR-0001 Phase 2 disposable native launcher qualification -- **Status:** Suspended-launch and Job Object policy spike complete; real worker launch blocked +- **Status:** Launch-plan boundary and policy contract complete; concrete worker launch and live broker binding blocked - **Phase:** 2 - fixed-function image provider - **Parent:** [Capability-tiered agentic execution harness](0001-capability-tiered-agentic-execution-harness.md) - **Depends on:** [Windows sandbox qualification](0001-phase2-sandbox-qualification.md) and [signed worker provenance](0001-phase2-worker-provenance.md) @@ -9,7 +9,22 @@ ## Decision `tools/execution_spikes/native_launcher_qualification.py` exercises the reviewed -native construction sequence without accepting a worker path or user input: +native construction sequence without accepting a worker path or user input. The +production boundary in `backend/cortex_backend/execution/native_launcher.py` now +accepts only an installer-verified worker and a trusted `BrokerWorkerBinding`: + +1. recheck the active signed generation through `verify_active_worker()`; +2. revalidate the fixed `recipe_worker.exe` identity, size, link count, and hash + immediately before launch planning; +3. construct a fixed command line containing only the native-broker endpoint and + expected broker PID; +4. require a reviewed native process factory and live broker binder before any + process is created; and +5. enforce the order `create suspended -> apply Job policy -> bind worker PID and + AppContainer SID -> resume`. + +The disposable qualification helper still exercises the lower-level construction +sequence with a fixed benign executable: 1. create a unique zero-capability AppContainer profile; 2. create a fixed `findstr.exe` child suspended with the AppContainer token; @@ -27,7 +42,9 @@ remain green after this change. This is qualification evidence for control application, not approval to launch a recipe worker. The fixed probe reports `provider_launch_authorized=false` until -the signed worker package and broker identity gates pass. +the signed worker package, concrete native process factory, and broker identity +gates pass. The production launcher boundary also fails closed when either adapter +is absent; it never falls back to `subprocess`, a shell, stdio, or a weaker sandbox. ## Evidence and limits @@ -48,6 +65,9 @@ behavior across supported Windows versions, or external launcher review. - The fixed signed `recipe_worker.exe` package is not shipped at the immutable installer generation used by the launcher. +- The reviewed Win32 process factory that creates the worker AppContainer token, + assigns and verifies the Job Object, and exposes a suspended process handle is + not implemented yet. - The native broker transport is not yet bound to the launched worker PID and OS token by a single reviewed launcher transaction. - Watchdog progress, output framing, staging ACLs, hostile decoder execution, and @@ -58,7 +78,11 @@ there is no host-process or weaker-sandbox fallback. ## Verification -`tests/test_native_launcher_qualification.py` covers non-Windows blocking, report -fail-closed behavior, and the no-breakaway policy invariant. The full repository -suite and the existing AppContainer/cancellation corpus are required before this -spike can be merged. +`tests/test_phase2_native_launcher.py` covers bounded policy values, no-breakaway +flags, trusted binding validation, worker revalidation, fixed command-line +construction, refusal before process creation without a binder, policy/bind/resume +ordering, cleanup on binding failure, tamper rejection, and non-Windows blocking. +`tests/test_native_launcher_qualification.py` covers the disposable probe's +non-Windows blocking, report fail-closed behavior, and no-breakaway invariant. The +full repository suite and the existing AppContainer/cancellation corpus are +required before this boundary can be merged. diff --git a/tests/test_phase2_native_launcher.py b/tests/test_phase2_native_launcher.py new file mode 100644 index 0000000..697397c --- /dev/null +++ b/tests/test_phase2_native_launcher.py @@ -0,0 +1,250 @@ +"""Fail-closed native launcher boundary tests.""" + +from __future__ import annotations + +import base64 +from hashlib import sha256 +import json +import os +from pathlib import Path +import subprocess + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +import cortex_backend.execution.native_launcher as launcher_module +from cortex_backend.execution.bundle_installer import SignedBundleInstaller +from cortex_backend.execution.native_launcher import ( + BrokerWorkerBinding, + NativeLauncherError, + NativeWorkerLaunchPlan, + NativeWorkerLauncher, + NativeWorkerPolicy, +) +from cortex_backend.execution.manifest import TrustedRecipeKeys + + +def _canonical(payload: dict) -> bytes: + return json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode( + "ascii" + ) + + +def _installer(tmp_path: Path) -> SignedBundleInstaller: + signer = Ed25519PrivateKey.generate() + public = signer.public_key().public_bytes( + serialization.Encoding.Raw, + serialization.PublicFormat.Raw, + ) + content = b"verified recipe worker fixture" + source = tmp_path / "incoming" + source.mkdir() + (source / "recipe_worker.exe").write_bytes(content) + unsigned = { + "schema_version": "recipe.manifest.v1", + "key_id": "release-1", + "sequence": 1, + "bundle_version": "1.0.0", + "rollback_of": None, + "entries": [ + { + "recipe_id": "image-transform", + "bundle_path": "recipe_worker.exe", + "entrypoint": "image_transform", + "version": "1.0.0", + "size": len(content), + "sha256": sha256(content).hexdigest(), + } + ], + } + payload = { + **unsigned, + "signature": base64.urlsafe_b64encode(signer.sign(_canonical(unsigned))) + .decode("ascii") + .rstrip("="), + } + installer = SignedBundleInstaller(tmp_path / "store", TrustedRecipeKeys({"release-1": public})) + installer.install(payload, source) + return installer + + +def _binding() -> BrokerWorkerBinding: + return BrokerWorkerBinding( + pipe_name=r"\\.\pipe\cortex-worker-test", + broker_process_id=321, + installation_principal_id="a" * 64, + job_id="job-1", + ) + + +def test_policy_is_bounded_and_has_no_breakaway_flags(): + policy = NativeWorkerPolicy() + assert policy.required_limit_flags == 0x230E + assert not policy.required_limit_flags & 0x1800 # BREAKAWAY_OK and SILENT_BREAKAWAY_OK. + + with pytest.raises(ValueError): + NativeWorkerPolicy(process_memory_limit_bytes=0) + with pytest.raises(ValueError): + NativeWorkerPolicy(job_memory_limit_bytes=1, process_memory_limit_bytes=2) + with pytest.raises(ValueError): + NativeWorkerPolicy(watchdog_timeout_ms=600_001) + + +@pytest.mark.parametrize( + "kwargs", + [ + {"pipe_name": r"\\.\pipe\other-worker"}, + {"broker_process_id": 0}, + {"installation_principal_id": "not-a-principal"}, + {"job_id": "../job"}, + ], +) +def test_broker_binding_rejects_untrusted_identity_values(kwargs): + values = { + "pipe_name": r"\\.\pipe\cortex-worker-test", + "broker_process_id": 321, + "installation_principal_id": "a" * 64, + "job_id": "job-1", + } + values.update(kwargs) + with pytest.raises(ValueError): + BrokerWorkerBinding(**values) + + +def test_prepare_reverifies_active_worker_and_builds_fixed_command_line(tmp_path: Path): + installer = _installer(tmp_path) + plan = NativeWorkerLauncher(installer).prepare(_binding()) + + assert plan.executable.name == "recipe_worker.exe" + assert plan.worker.worker_path == "recipe_worker.exe" + assert plan.broker.broker_process_id == 321 + assert subprocess.list2cmdline( + [ + str(plan.executable), + "--native-broker", + "--broker-pipe", + _binding().pipe_name, + "--broker-pid", + "321", + ] + ) == plan.command_line + assert "shell" not in plan.command_line.casefold() + + +def test_launch_refuses_before_process_creation_without_live_broker_binder(tmp_path: Path): + installer = _installer(tmp_path) + created = [] + + class _Factory: + def create_suspended(self, plan: NativeWorkerLaunchPlan): + created.append(plan) + raise AssertionError("factory must not run before broker binding exists") + + launcher = NativeWorkerLauncher(installer, process_factory=_Factory()) + with pytest.raises(NativeLauncherError) as error: + launcher.launch(_binding()) + assert error.value.code == "native_broker_binding_required" + assert created == [] + + +@pytest.mark.skipif(os.name != "nt", reason="native launch orchestration is Windows-only") +def test_launch_orders_policy_binding_and_resume(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + installer = _installer(tmp_path) + events: list[str] = [] + + class _Worker: + process_id = 444 + app_container_sid = "S-1-15-2-123-456" + + def apply_job_policy(self, policy: NativeWorkerPolicy) -> None: + assert policy.required_limit_flags == 0x230E + events.append("policy") + + def resume(self) -> None: + events.append("resume") + + def close(self) -> None: + events.append("close") + + class _Factory: + def create_suspended(self, plan: NativeWorkerLaunchPlan): + assert plan.worker.worker_path == "recipe_worker.exe" + events.append("create") + return _Worker() + + class _Binder: + def bind_worker(self, *, process_id: int, app_container_sid: str, binding: BrokerWorkerBinding): + assert process_id == 444 + assert app_container_sid == "S-1-15-2-123-456" + assert binding.job_id == "job-1" + events.append("bind") + + result = NativeWorkerLauncher( + installer, + process_factory=_Factory(), + broker_binder=_Binder(), + ).launch(_binding()) + + assert result.process_id == 444 + assert events == ["create", "policy", "bind", "resume"] + + +@pytest.mark.skipif(os.name != "nt", reason="native launch orchestration is Windows-only") +def test_broker_binding_failure_closes_worker_without_resume(tmp_path: Path): + installer = _installer(tmp_path) + events: list[str] = [] + + class _Worker: + process_id = 445 + app_container_sid = "S-1-15-2-123-456" + + def apply_job_policy(self, policy: NativeWorkerPolicy) -> None: + events.append("policy") + + def resume(self) -> None: + events.append("resume") + + def close(self) -> None: + events.append("close") + + class _Factory: + def create_suspended(self, plan: NativeWorkerLaunchPlan): + events.append("create") + return _Worker() + + class _Binder: + def bind_worker(self, **kwargs): + del kwargs + events.append("bind") + raise NativeLauncherError("native_peer_identity_mismatch") + + with pytest.raises(NativeLauncherError) as error: + NativeWorkerLauncher( + installer, + process_factory=_Factory(), + broker_binder=_Binder(), + ).launch(_binding()) + assert error.value.code == "native_peer_identity_mismatch" + assert events == ["create", "policy", "bind", "close"] + + +def test_tampered_worker_is_rejected_at_launch_plan_boundary(tmp_path: Path): + installer = _installer(tmp_path) + installed = installer.status() + assert installed is not None + (installed.bundle_root / "recipe_worker.exe").write_bytes(b"tampered") + + with pytest.raises(NativeLauncherError) as error: + NativeWorkerLauncher(installer).prepare(_binding()) + assert error.value.code == "worker_bundle_integrity_failed" + + +def test_non_windows_launch_is_blocked_before_adapters(monkeypatch: pytest.MonkeyPatch, tmp_path: Path): + installer = _installer(tmp_path) + monkeypatch.setattr(launcher_module.os, "name", "posix") + with pytest.raises(NativeLauncherError) as error: + NativeWorkerLauncher(installer, process_factory=object(), broker_binder=object()).launch( + _binding() + ) + assert error.value.code == "native_windows_required" diff --git a/tools/execution_spikes/README.md b/tools/execution_spikes/README.md index 08aa7fb..bb71b83 100644 --- a/tools/execution_spikes/README.md +++ b/tools/execution_spikes/README.md @@ -89,6 +89,10 @@ provider. The package contract is covered by `tests/test_phase2_worker_protocol. - `native_launcher_qualification`: creates only a fixed suspended `findstr.exe` child, applies and queries Job Object resource policy before resume, and reports the signed-worker and broker-binding blockers without launching either. +- `backend/cortex_backend/execution/native_launcher.py`: production-facing + launch-plan boundary that revalidates the signed worker and refuses process + creation until a reviewed native process factory and live broker binder are + supplied. It does not provide a fallback launcher. - `worker_protocol`: validates the future worker's bounded request state machine, in-order hashed chunks, cancellation, and redacted output contract; it has no filesystem, process, or transport capability.