From 4986b6cfe5132cd67fb7ad4b13b9a5f0208f1500 Mon Sep 17 00:00:00 2001 From: codex-scheduled Date: Wed, 12 Aug 2026 11:50:53 -0700 Subject: [PATCH] feat(rate): host private Morris authority --- AGENT_HANDOFF.md | 29 +++ SPEC.md | 5 +- .../RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md | 30 +++ pyproject.toml | 9 + src/rate_of_closure/AGENT_HANDOFF.md | 28 ++ .../application/morris/child.py | 96 +++++++ .../application/morris/host.py | 108 ++++++++ .../application/morris/runtime.py | 243 ++++++++++++++++++ src/rate_of_closure/launch_web.py | 13 +- .../web/morrisAuthorityProxy.test.ts | 46 ++++ .../web/morrisAuthorityProxy.ts | 57 ++++ .../web/src/model/morrisAuthorityClient.ts | 2 +- .../src/model/morrisAuthorityContract.test.ts | 12 + src/rate_of_closure/web/tsconfig.node.json | 2 +- src/rate_of_closure/web/vite.config.ts | 14 +- .../python/gui_launcher/launcher_web.py | 8 +- .../test_morris_authority_child.py | 146 +++++++++++ .../test_morris_authority_host.py | 116 +++++++++ .../test_morris_authority_runtime.py | 146 +++++++++++ .../test_rate_launch_web_authority.py | 63 +++++ tests/test_gui_launcher_web.py | 19 ++ uv.lock | 20 +- 22 files changed, 1199 insertions(+), 13 deletions(-) create mode 100644 src/rate_of_closure/application/morris/child.py create mode 100644 src/rate_of_closure/application/morris/host.py create mode 100644 src/rate_of_closure/application/morris/runtime.py create mode 100644 src/rate_of_closure/web/morrisAuthorityProxy.test.ts create mode 100644 src/rate_of_closure/web/morrisAuthorityProxy.ts create mode 100644 tests/rate_of_closure/test_morris_authority_child.py create mode 100644 tests/rate_of_closure/test_morris_authority_host.py create mode 100644 tests/rate_of_closure/test_morris_authority_runtime.py create mode 100644 tests/rate_of_closure/test_rate_launch_web_authority.py diff --git a/AGENT_HANDOFF.md b/AGENT_HANDOFF.md index a4e95e95ab..3366142582 100644 --- a/AGENT_HANDOFF.md +++ b/AGENT_HANDOFF.md @@ -3,6 +3,35 @@ > **Update this file with every PR and every push to main.** > Last updated: 2026-08-12 +## 2026-08-12 Private Morris authority host (#4142 R13.5) + +Branch `codex/4142-morris-authority-host` starts at exact authority-bridge +parent `3c95dcaf88c4a0eacc747b48678e1f5c225f12ec`. The standalone Rate React +launcher now owns an ephemeral child-process authority for exactly the Vite +development-server lifetime. The child binds IPv4 `127.0.0.1:0`, announces a +canonical numeric port over a bounded private pipe, proves the exact +authenticated capability document, and exits through an authenticated graceful +control request with bounded terminate/kill fallback. The bearer is redacted +from runtime representations, never uses a `VITE_` variable, and is injected +only into Vite's server-side `/api/rate-of-closure` proxy. Every response is +`no-store`/`nosniff`; there is no CORS, docs, OpenAPI, browser token, or browser +authority endpoint. Authenticated 404, validation, and sanitized unhandled-500 +responses retain the same headers without exposing exception details. +`KeyboardInterrupt` and `SystemExit` during post-spawn readiness reap the child +and close the bounded readiness pipe before propagating unchanged; secondary +terminate/wait/pipe failures are contained and cannot replace the primary +startup exception. + +The canonical authority prefix is `/api/rate-of-closure/v1`; capability is +`/api/rate-of-closure/v1/morris/capabilities`. Host lifespan owns the injected +registry exactly once after startup transfer; the child closes it when socket, +app, or server setup fails before lifespan. Listener and registry cleanup are +both attempted, with secondary failures contained whenever a primary setup +error is active. Optional `rate-morris-authority` dependencies are +FastAPI, Uvicorn, and SciPy. This is a local development-launch host, not a +static-preview or deployed-host contract. UI polling/presentation, export, +persistence, UpstreamDrift consumption, and completion of #4142 remain open. + ## 2026-08-12 Rate Morris authority bridge (#4142 R13.5) Exact request/job v1 contracts and `RateMorrisService` now bridge the current diff --git a/SPEC.md b/SPEC.md index c1d090cbd8..33ad17979e 100644 --- a/SPEC.md +++ b/SPEC.md @@ -26,8 +26,8 @@ | **Owner** | D-sorganization | | **Primary Language(s)** | Python 3.11+, Rust, JavaScript, TypeScript | | **License** | MIT | -| **Current Version** | 1.16.28 | -| **Spec Version** | 1.16.28 | +| **Current Version** | 1.16.29 | +| **Spec Version** | 1.16.29 | | **Last Spec Update** | 2026-08-12 | ## 2. Purpose & Mission @@ -2922,6 +2922,7 @@ Active development with stable core, continuous tool expansion, and web API in p | Date | Version | Changes | | ---- | ------- | ------- | +| 2026-08-12 | 1.16.29 | feat(rate-of-closure, #4142 R13.5): add a bounded private Morris authority host for the standalone React development launcher: exclusive ephemeral IPv4 loopback child socket, exact authenticated capability readiness, redacted bearer, no-store/nosniff no-CORS FastAPI host including sanitized authenticated errors, explicit pre-lifespan-to-ASGI exact-once registry ownership transfer, BaseException-safe startup cleanup and pipe closure that preserve the primary error through secondary cleanup failures, graceful authenticated shutdown with bounded reap fallback, and a strict server-only Vite proxy at the canonical `/api/rate-of-closure/v1` prefix. Declare the optional FastAPI/Uvicorn/SciPy host dependencies and retain UI polling/presentation, export, persistence, static or deployed authority hosting, UpstreamDrift consumption, and epic completion as open gates. | | 2026-08-12 | 1.16.28 | feat(rate-of-closure, #4142 R13.5): add exact primitive-only Morris request/job v1 contracts, deterministic execution into unchanged report v1, a dependency-injected mountable FastAPI router with strict bounded raw JSON and lock-linearized ephemeral jobs, and a strict TypeScript parser plus injected transport. Retain presentation, export, persistence, host registration, UpstreamDrift consumption, and a genuine fixed-ball double-pendulum hit as open gates. | | 2026-08-12 | 1.16.27 | feat(rate-of-closure, #4142 R13.3): add the bounded Rate fixed-ball Morris evaluator for ten exact global simulation variables and the current 17-scalar output contract; extract shared trial capture/projection so ensemble and Morris execution retain identical hit/miss/numerical-failure availability, apply samples through one public immutable config seam, reject fixed-contact timing no-ops/localized or invalid factors, and validate a genuine double-pendulum miss while retaining double-pendulum fixed-hit validation, UI/export, per-sample failure diagnostics, and UpstreamDrift consumption as open gates. | | 2026-08-12 | 1.16.26 | feat(rate-of-closure, #4142 R13.3): add a bounded UI-neutral Morris execution adapter with immutable physical sample identity, injected typed evaluators that explicitly normalize their own domain failures, exact per-output availability, deterministic serial/parallel tensors and completed-prefix progress every eight samples plus final, cooperative no-partial-result cancellation, and named worker/sample/observation-cell resource limits; retain Rate, UI, export, and `evaluate_run` integration as later scope. | diff --git a/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md b/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md index 67f176d2b7..d12a4c0962 100644 --- a/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md +++ b/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md @@ -1,5 +1,35 @@ # Rate of Closure Campaign Handoff +## 2026-08-12 #4142 R13.5 private authority host + +- The standalone Rate React launcher now owns one ephemeral authority child + around the Vite development server. The child exclusively binds IPv4 + `127.0.0.1:0`; the parent accepts only a bounded canonical port line and an + exact authenticated capability response before launching Vite. +- The canonical API is `/api/rate-of-closure/v1`; capability discovery is + `/api/rate-of-closure/v1/morris/capabilities` and advertises the exact request + and job schema IDs. All routes require the generated bearer and return + `Cache-Control: no-store` plus `X-Content-Type-Options: nosniff`; there is no + CORS, docs, OpenAPI, browser credential, or browser-visible target variable. +- Vite's development server alone receives `ROC_MORRIS_AUTHORITY_URL` and + `ROC_MORRIS_AUTHORITY_TOKEN` and proxies `/api/rate-of-closure`. Targets are + strict credential-free IPv4-loopback origins with valid explicit ports; + tokens are visible ASCII. Preview/static hosting intentionally has no proxy. +- Registry ownership is exact-once through FastAPI lifespan. Parent shutdown is + authenticated and graceful first, with bounded terminate/kill fallback; + startup interrupts and launch errors also reap the child and close its pipe. + Secondary reap/pipe errors are contained without replacing the original + startup exception. Listener and registry cleanup are both attempted even + when either cleanup operation fails. + Before lifespan transfer, child-side setup failures close the registry; + afterward ASGI lifespan owns the one close. Authenticated 404, 422, and + sanitized unhandled-500 responses retain security headers. Real child smoke, host + lifecycle/security tests, proxy rejection tests, TypeScript gates, and the + production web build are green locally. +- This does not add the Morris UI, polling, persistence, export, production + authority deployment, or UpstreamDrift consumption and does not complete + #4142. + ## 2026-08-12 #4142 R13.5 bounded authority bridge - Added exact request/job v1 contracts, deterministic Rate execution, bounded diff --git a/pyproject.toml b/pyproject.toml index 36736c4d43..e8964ccb76 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,12 @@ dependencies = [ ] [project.optional-dependencies] +# Private loopback authority used by the Rate React launcher. +rate-morris-authority = [ + "fastapi>=0.100.0", + "scipy>=1.10.0,<1.18", + "uvicorn>=0.30.0", +] # Full installation with all features all = [ "scipy>=1.10.0,<1.18", @@ -40,6 +46,8 @@ all = [ "mujoco>=3.0.0", "PyQt6>=6.5.0", "sympy>=1.12", + "fastapi>=0.100.0", + "uvicorn>=0.30.0", ] # AI and simulation models ai = [ @@ -152,6 +160,7 @@ test = [ "python-multipart>=0.0.7", "requests>=2.32.0", "sqlmodel>=0.0.22", + "uvicorn>=0.30.0", ] # Development tools dev = [ diff --git a/src/rate_of_closure/AGENT_HANDOFF.md b/src/rate_of_closure/AGENT_HANDOFF.md index 3b331cd0f5..7b1a5a4b12 100644 --- a/src/rate_of_closure/AGENT_HANDOFF.md +++ b/src/rate_of_closure/AGENT_HANDOFF.md @@ -3,6 +3,34 @@ > **Update this file with every PR and every push to main.** > Last updated: 2026-08-12 +## 2026-08-12 Private child authority and Vite proxy (#4142 R13.5) + +`application/morris/{host,child,runtime}.py` is the bounded local host slice. +The child alone owns an exclusive IPv4 loopback port-zero socket; parent +readiness requires the exact bearer-authenticated capability document at +`/api/rate-of-closure/v1/morris/capabilities`, including both request and job +schema identities. Parent probes use direct `http.client` connections so proxy +environment variables cannot receive the credential. Host lifespan closes an +injected `MorrisJobRegistry` exactly once; shutdown first uses an authenticated +control route and then a bounded reap fallback. `runtime.py` does not import the +Uvicorn-owning child module and redacts its token from `repr`. Post-spawn +`BaseException` paths reap before re-raising and every final reap closes the +readiness pipe; cleanup failures are generically logged and never replace the +original startup exception. Pre-lifespan setup failures remain child-owned; +listener and registry cleanup are both attempted without masking setup errors; successful +lifespan startup explicitly transfers registry ownership, avoiding leaks and +double close. Sanitized authenticated 500s, plus 404/422 paths, retain +no-store/nosniff headers. + +`launch_web.py` wraps the complete shared Vite launcher call in that runtime. +The Vite development server proxies `/api/rate-of-closure` using only +`ROC_MORRIS_AUTHORITY_URL` and `ROC_MORRIS_AUTHORITY_TOKEN`; strict validation +rejects non-loopback, credentialed, path/query/fragment, invalid-port, and +header-unsafe configuration. Preview/static builds do not proxy the authority, +and no `VITE_` value or CORS path exposes the endpoint/token to browser code. +The React client defaults to the canonical same-origin v1 prefix. UI startup, +polling, display, export, persistence, and non-development hosting remain open. + ## 2026-08-12 Morris authority bridge (#4142 R13.5) `application/morris/` now provides strict primitive request/job v1 contracts, diff --git a/src/rate_of_closure/application/morris/child.py b/src/rate_of_closure/application/morris/child.py new file mode 100644 index 0000000000..f920f5ef55 --- /dev/null +++ b/src/rate_of_closure/application/morris/child.py @@ -0,0 +1,96 @@ +"""Child-process entry point owning the authority's ephemeral loopback socket.""" + +from __future__ import annotations + +import logging +import os +import socket +import sys + +import uvicorn + +from .host import create_morris_authority_app +from .router import MorrisJobRegistry +from .service import RateMorrisService + +AUTHORITY_TOKEN_ENV = "ROC_MORRIS_AUTHORITY_CHILD_TOKEN" +logger = logging.getLogger(__name__) + + +def _private_token() -> str: + token = os.environ.pop(AUTHORITY_TOKEN_ENV, "") + if len(token) < 8: + raise RuntimeError("missing Morris authority child token") + return token + + +def _cleanup( + listener: socket.socket, + registry: MorrisJobRegistry | None, + lifespan_owns_registry: bool, +) -> None: + """Attempt every child cleanup without replacing an active primary error.""" + preserve_primary = sys.exception() is not None + cleanup_error: BaseException | None = None + try: + listener.close() + except BaseException as error: + cleanup_error = error + logger.warning("Morris authority listener cleanup failed") + if registry is not None and not lifespan_owns_registry: + try: + registry.close() + except BaseException as error: + if cleanup_error is None: + cleanup_error = error + logger.warning("Morris authority registry cleanup failed") + if cleanup_error is not None and not preserve_primary: + raise cleanup_error.with_traceback(cleanup_error.__traceback__) + + +def main() -> int: + """Bind IPv4 loopback port zero, announce its port, and serve until stopped.""" + token = _private_token() + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + registry: MorrisJobRegistry | None = None + lifespan_owns_registry = False + + def transfer_registry() -> None: + nonlocal lifespan_owns_registry + lifespan_owns_registry = True + + try: + registry = MorrisJobRegistry(RateMorrisService()) + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 0) + exclusive = getattr(socket, "SO_EXCLUSIVEADDRUSE", None) + if exclusive is not None: + listener.setsockopt(socket.SOL_SOCKET, exclusive, 1) + listener.bind(("127.0.0.1", 0)) + listener.listen(socket.SOMAXCONN) + port = int(listener.getsockname()[1]) + holder: dict[str, uvicorn.Server] = {} + app = create_morris_authority_app( + token, + registry, + lambda: setattr(holder["server"], "should_exit", True), + lifespan_started=transfer_registry, + ) + config = uvicorn.Config( + app, + log_level="warning", + access_log=False, + server_header=False, + ) + server = uvicorn.Server(config) + holder["server"] = server + print( # noqa: T201 - private parent/child readiness protocol + port, flush=True + ) + server.run(sockets=[listener]) + return 0 + finally: + _cleanup(listener, registry, lifespan_owns_registry) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/rate_of_closure/application/morris/host.py b/src/rate_of_closure/application/morris/host.py new file mode 100644 index 0000000000..3121741523 --- /dev/null +++ b/src/rate_of_closure/application/morris/host.py @@ -0,0 +1,108 @@ +"""Authenticated loopback-only FastAPI host for the Morris authority router.""" + +from __future__ import annotations + +import hmac +from collections.abc import AsyncIterator, Awaitable, Callable +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse +from starlette.responses import Response + +from .contracts import MORRIS_JOB_SCHEMA_ID, MORRIS_REQUEST_SCHEMA_ID +from .router import MorrisJobRegistry, create_morris_router + +API_PREFIX = "/api/rate-of-closure/v1" +CAPABILITY_PATH = f"{API_PREFIX}/morris/capabilities" +_CAPABILITY = { + "schema_id": "rate-of-closure/morris-authority-capability", + "schema_version": 1, + "available": True, + "api_prefix": API_PREFIX, + "request_schema_id": MORRIS_REQUEST_SCHEMA_ID, + "job_schema_id": MORRIS_JOB_SCHEMA_ID, +} + + +def _token(value: object) -> str: + if not isinstance(value, str) or len(value) < 8 or value != value.strip(): + raise ValueError("authority token must be a nonempty private token") + if any(ord(character) < 33 or ord(character) > 126 for character in value): + raise ValueError("authority token must contain visible ASCII only") + return value + + +def create_morris_authority_app( + token: str, + registry: MorrisJobRegistry, + shutdown: Callable[[], None] | None = None, + *, + lifespan_started: Callable[[], None] | None = None, +) -> FastAPI: + """Build a no-CORS, bearer-authenticated mountable authority app.""" + secret = _token(token) + if not isinstance(registry, MorrisJobRegistry): + raise TypeError("registry must be a MorrisJobRegistry") + + @asynccontextmanager + async def lifespan(_app: FastAPI) -> AsyncIterator[None]: + if lifespan_started is not None: + lifespan_started() + try: + yield + finally: + registry.close() + + app = FastAPI( + title="Rate Morris Authority", + docs_url=None, + redoc_url=None, + openapi_url=None, + lifespan=lifespan, + ) + + def secured(response: Response) -> Response: + response.headers["Cache-Control"] = "no-store" + response.headers["X-Content-Type-Options"] = "nosniff" + return response + + @app.exception_handler(Exception) + async def internal_error(_request: Request, _error: Exception) -> Response: + return secured( + JSONResponse({"error": "internal server error"}, status_code=500) + ) + + @app.middleware("http") + async def secure( + request: Request, + call_next: Callable[[Request], Awaitable[Response]], + ) -> Response: + supplied = request.headers.get("authorization", "") + expected = f"Bearer {secret}" + response: Response + if not hmac.compare_digest(supplied, expected): + response = JSONResponse( + {"error": "authentication required"}, status_code=401 + ) + response.headers["WWW-Authenticate"] = "Bearer" + else: + response = await call_next(request) + return secured(response) + + @app.get(CAPABILITY_PATH) + async def capability() -> dict[str, object]: + return dict(_CAPABILITY) + + @app.post("/_control/shutdown") + async def stop_child() -> dict[str, str]: + if shutdown is None: + return {"status": "unavailable"} + shutdown() + return {"status": "stopping"} + + app.include_router(create_morris_router(registry), prefix=API_PREFIX) + return app + + +__all__ = ["API_PREFIX", "CAPABILITY_PATH", "create_morris_authority_app"] diff --git a/src/rate_of_closure/application/morris/runtime.py b/src/rate_of_closure/application/morris/runtime.py new file mode 100644 index 0000000000..05b05a756b --- /dev/null +++ b/src/rate_of_closure/application/morris/runtime.py @@ -0,0 +1,243 @@ +"""Bounded parent-side lifecycle for the private Morris authority child.""" + +from __future__ import annotations + +import http.client +import json +import logging +import os +import secrets +import subprocess +import sys +import time +from concurrent.futures import ThreadPoolExecutor, TimeoutError +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from .contracts import MORRIS_JOB_SCHEMA_ID, MORRIS_REQUEST_SCHEMA_ID + +AUTHORITY_TOKEN_ENV = "ROC_MORRIS_AUTHORITY_CHILD_TOKEN" +API_PREFIX = "/api/rate-of-closure/v1" +CAPABILITY_PATH = f"{API_PREFIX}/morris/capabilities" +_CONTROL_PATH = "/_control/shutdown" +_EXPECTED_CAPABILITY = { + "schema_id": "rate-of-closure/morris-authority-capability", + "schema_version": 1, + "available": True, + "api_prefix": API_PREFIX, + "request_schema_id": MORRIS_REQUEST_SCHEMA_ID, + "job_schema_id": MORRIS_JOB_SCHEMA_ID, +} +_POLL_INTERVAL_S = 0.05 +_CLOSE_TIMEOUT_S = 5.0 +_MAX_CAPABILITY_BYTES = 1_024 +logger = logging.getLogger(__name__) + + +@dataclass +class MorrisAuthorityRuntime: + """One authenticated child and its server-only Vite proxy environment.""" + + process: subprocess.Popen[str] + base_url: str + token: str = field(repr=False) + _closed: bool = False + + @classmethod + def start( + cls, + source_root: Path | None = None, + startup_timeout_s: float = 10.0, + ) -> MorrisAuthorityRuntime: + """Spawn the child and return only after authenticated capability proof.""" + timeout = _startup_timeout(startup_timeout_s) + root = _source_root(source_root) + token = secrets.token_urlsafe(32) + process = _spawn_child(root, token) + deadline = time.monotonic() + timeout + try: + port = _ready_port(process, timeout) + base_url = f"http://127.0.0.1:{port}" + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("Morris authority startup timed out") + _wait_authenticated(process, port, token, remaining) + return cls(process, base_url, token) + except BaseException: + try: + _force_reap(process) + except BaseException: + logger.warning("Morris authority startup cleanup failed") + raise + + @property + def authorization_headers(self) -> dict[str, str]: + """Return detached private request headers for parent-only control.""" + return {"Authorization": f"Bearer {self.token}"} + + @property + def vite_env(self) -> dict[str, str]: + """Return detached server-only proxy variables for the Vite process.""" + return { + "ROC_MORRIS_AUTHORITY_URL": self.base_url, + "ROC_MORRIS_AUTHORITY_TOKEN": self.token, + } + + def close(self) -> None: + """Idempotently request graceful shutdown, then reap with bounded fallback.""" + if self._closed: + return + self._closed = True + if self.process.poll() is None: + _request_shutdown(self.process, self.base_url, self.authorization_headers) + _force_reap(self.process, allow_grace=True) + + def __enter__(self) -> MorrisAuthorityRuntime: + return self + + def __exit__(self, *_details: object) -> None: + self.close() + + +def _startup_timeout(value: object) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError("startup_timeout_s must be within (0, 60]") + timeout = float(value) + if not 0 < timeout <= 60: + raise ValueError("startup_timeout_s must be within (0, 60]") + return timeout + + +def _source_root(value: Path | None) -> Path: + root = Path(__file__).resolve().parents[3] if value is None else Path(value) + root = root.resolve() + if not root.is_dir() or not (root / "rate_of_closure").is_dir(): + raise ValueError("source_root must contain the rate_of_closure package") + return root + + +def _spawn_child(root: Path, token: str) -> subprocess.Popen[str]: + interpreter = Path(sys.executable).resolve() + if not interpreter.is_file(): + raise RuntimeError("current Python interpreter path is unavailable") + environment = os.environ.copy() + environment[AUTHORITY_TOKEN_ENV] = token + prior_path = environment.get("PYTHONPATH") + environment["PYTHONPATH"] = ( + str(root) if not prior_path else os.pathsep.join((str(root), prior_path)) + ) + return subprocess.Popen( + [str(interpreter), "-m", "rate_of_closure.application.morris.child"], + env=environment, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + shell=False, + ) + + +def _ready_port(process: subprocess.Popen[str], timeout_s: float) -> int: + if process.stdout is None: + raise RuntimeError("authority readiness channel is unavailable") + pool = ThreadPoolExecutor(max_workers=1, thread_name_prefix="morris-ready") + future = pool.submit(process.stdout.readline, 16) + try: + line = future.result(timeout=timeout_s) + except TimeoutError as exc: + raise TimeoutError("Morris authority child readiness timed out") from exc + finally: + pool.shutdown(wait=False, cancel_futures=True) + if not line.endswith("\n") or not line[:-1].isdigit() or len(line) > 6: + raise RuntimeError("invalid Morris authority child readiness") + port = int(line[:-1]) + if not 1 <= port <= 65535 or line != f"{port}\n": + raise RuntimeError("invalid Morris authority child readiness") + return port + + +def _wait_authenticated( + process: subprocess.Popen[str], port: int, token: str, timeout_s: float +) -> None: + deadline = time.monotonic() + timeout_s + headers = {"Authorization": f"Bearer {token}"} + while time.monotonic() < deadline: + if process.poll() is not None: + raise RuntimeError("Morris authority child exited before readiness") + try: + status, media_type, document = _direct_request( + port, "GET", CAPABILITY_PATH, headers + ) + if ( + status == 200 + and media_type == "application/json" + and document == _EXPECTED_CAPABILITY + ): + return + except (OSError, http.client.HTTPException, ValueError): + pass + time.sleep(_POLL_INTERVAL_S) + raise TimeoutError("Morris authority authenticated readiness timed out") + + +def _direct_request( + port: int, method: str, path: str, headers: dict[str, str] +) -> tuple[int, str, object]: + connection = http.client.HTTPConnection("127.0.0.1", port, timeout=0.5) + try: + connection.request(method, path, headers=headers) + response = connection.getresponse() + body = response.read(_MAX_CAPABILITY_BYTES + 1) + if len(body) > _MAX_CAPABILITY_BYTES: + raise ValueError("authority response exceeds the readiness bound") + media_type = response.getheader("Content-Type", "").split(";", 1)[0].lower() + document: Any = json.loads(body.decode("utf-8", errors="strict")) + return response.status, media_type, document + finally: + connection.close() + + +def _request_shutdown( + process: subprocess.Popen[str], base_url: str, headers: dict[str, str] +) -> None: + port = int(base_url.rsplit(":", 1)[1]) + try: + _direct_request(port, "POST", _CONTROL_PATH, headers) + process.wait(timeout=_CLOSE_TIMEOUT_S) + except (OSError, ValueError, http.client.HTTPException, subprocess.TimeoutExpired): + return + + +def _force_reap(process: subprocess.Popen[str], allow_grace: bool = False) -> None: + reap_error: BaseException | None = None + try: + if process.poll() is not None: + process.wait() + elif allow_grace: + try: + process.wait(timeout=0.1) + except subprocess.TimeoutExpired: + pass + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=_CLOSE_TIMEOUT_S) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=_CLOSE_TIMEOUT_S) + except BaseException as error: + reap_error = error + try: + stream = process.stdout + if stream is not None and not stream.closed: + stream.close() + except BaseException: + if reap_error is None: + raise + logger.warning("Morris authority pipe cleanup failed") + if reap_error is not None: + raise reap_error.with_traceback(reap_error.__traceback__) + + +__all__ = ["MorrisAuthorityRuntime"] diff --git a/src/rate_of_closure/launch_web.py b/src/rate_of_closure/launch_web.py index 7dd11af692..dff2ab3f79 100644 --- a/src/rate_of_closure/launch_web.py +++ b/src/rate_of_closure/launch_web.py @@ -17,7 +17,18 @@ from shared.python.gui_launcher import launch_web_from_gui_info # noqa: E402 +from .application.morris.runtime import MorrisAuthorityRuntime # noqa: E402 from .gui_registration import GUI_INFO # noqa: E402 + +def main() -> int: + """Own the private Morris authority for the complete Vite lifecycle.""" + with MorrisAuthorityRuntime.start() as authority: + result: int = launch_web_from_gui_info( + GUI_INFO, __file__, env_vars=authority.vite_env + ) + return result + + if __name__ == "__main__": - sys.exit(launch_web_from_gui_info(GUI_INFO, __file__)) + sys.exit(main()) diff --git a/src/rate_of_closure/web/morrisAuthorityProxy.test.ts b/src/rate_of_closure/web/morrisAuthorityProxy.test.ts new file mode 100644 index 0000000000..1c29ae84d3 --- /dev/null +++ b/src/rate_of_closure/web/morrisAuthorityProxy.test.ts @@ -0,0 +1,46 @@ +/** Server-only authority proxy configuration tests. */ + +import { describe, expect, it } from "vitest"; + +import { morrisAuthorityProxy } from "./morrisAuthorityProxy"; + +describe("Morris authority Vite proxy", () => { + it("injects the bearer only in the server proxy", () => { + const proxy = morrisAuthorityProxy({ + ROC_MORRIS_AUTHORITY_URL: "http://127.0.0.1:43210", + ROC_MORRIS_AUTHORITY_TOKEN: "server-secret", + }); + expect(proxy["/api/rate-of-closure"]).toMatchObject({ + target: "http://127.0.0.1:43210", + headers: { Authorization: "Bearer server-secret" }, + }); + expect(JSON.stringify(proxy)).not.toContain("VITE_"); + }); + + it("is absent without runtime vars and fails closed on partial/nonloopback config", () => { + expect(morrisAuthorityProxy({})).toEqual({}); + expect(() => morrisAuthorityProxy({ ROC_MORRIS_AUTHORITY_TOKEN: "secret" })).toThrow(); + }); + + it.each([ + "http://0.0.0.0:8000", + `http://user:${"password"}@127.0.0.1:8000`, + "http://127.0.0.1:8000/path", + "http://127.0.0.1:8000?query=yes", + "http://127.0.0.1:8000#fragment", + "http://127.0.0.1:99999", + "http://127.0.0.1:8000\r\nheader:value", + ])("rejects unsafe target %s", (target) => { + expect(() => morrisAuthorityProxy({ + ROC_MORRIS_AUTHORITY_URL: target, + ROC_MORRIS_AUTHORITY_TOKEN: "server-secret", + })).toThrow(); + }); + + it.each([" short ", "bad\r\ntoken", "seven77"])("rejects unsafe token %s", (token) => { + expect(() => morrisAuthorityProxy({ + ROC_MORRIS_AUTHORITY_URL: "http://127.0.0.1:8000", + ROC_MORRIS_AUTHORITY_TOKEN: token, + })).toThrow("token"); + }); +}); diff --git a/src/rate_of_closure/web/morrisAuthorityProxy.ts b/src/rate_of_closure/web/morrisAuthorityProxy.ts new file mode 100644 index 0000000000..cfb34c01b5 --- /dev/null +++ b/src/rate_of_closure/web/morrisAuthorityProxy.ts @@ -0,0 +1,57 @@ +/** Server-only Vite proxy for the private loopback Morris authority. */ + +import type { ProxyOptions } from "vite"; + +export type AuthorityProxy = Record; + +const TARGET = "ROC_MORRIS_AUTHORITY_URL"; +const TOKEN = "ROC_MORRIS_AUTHORITY_TOKEN"; + +const loopbackTarget = (value: string): string => { + let parsed: URL; + try { + parsed = new URL(value); + } catch { + throw new RangeError("Morris authority target must be a valid URL"); + } + const exactOrigin = parsed.protocol === "http:" + && parsed.hostname === "127.0.0.1" + && parsed.port !== "" + && Number.isInteger(Number(parsed.port)) + && Number(parsed.port) >= 1 + && Number(parsed.port) <= 65_535 + && parsed.username === "" + && parsed.password === "" + && parsed.pathname === "/" + && parsed.search === "" + && parsed.hash === ""; + if (!exactOrigin) throw new RangeError("Morris authority target must be an IPv4 loopback origin"); + return parsed.origin; +}; + +const privateToken = (value: string): string => { + const visibleAscii = [...value].every((character) => { + const code = character.charCodeAt(0); + return code >= 33 && code <= 126; + }); + if (value.length < 8 || value !== value.trim() || !visibleAscii) { + throw new RangeError("Morris authority token is invalid"); + } + return value; +}; + +export function morrisAuthorityProxy(environment: AuthorityProxy): Record { + const target = environment[TARGET]; + const token = environment[TOKEN]; + if (target === undefined && token === undefined) return {}; + if (target === undefined || token === undefined) { + throw new RangeError("Morris authority proxy requires target and token"); + } + return { + "/api/rate-of-closure": { + target: loopbackTarget(target), + changeOrigin: false, + headers: { Authorization: `Bearer ${privateToken(token)}` }, + }, + }; +} diff --git a/src/rate_of_closure/web/src/model/morrisAuthorityClient.ts b/src/rate_of_closure/web/src/model/morrisAuthorityClient.ts index 0e26716564..6e3fe055a4 100644 --- a/src/rate_of_closure/web/src/model/morrisAuthorityClient.ts +++ b/src/rate_of_closure/web/src/model/morrisAuthorityClient.ts @@ -50,7 +50,7 @@ const responseDocument = async (response: Response): Promise => { }; export function createMorrisAuthorityClient(options: MorrisAuthorityClientOptions = {}): MorrisAuthorityClient { - const baseUrl = normalizedBaseUrl(options.baseUrl ?? ""); + const baseUrl = normalizedBaseUrl(options.baseUrl ?? "/api/rate-of-closure/v1"); const fetchImpl = options.fetchImpl ?? globalThis.fetch; if (typeof fetchImpl !== "function") throw new TypeError("fetchImpl must be callable"); diff --git a/src/rate_of_closure/web/src/model/morrisAuthorityContract.test.ts b/src/rate_of_closure/web/src/model/morrisAuthorityContract.test.ts index 27e839aedd..400d6f6270 100644 --- a/src/rate_of_closure/web/src/model/morrisAuthorityContract.test.ts +++ b/src/rate_of_closure/web/src/model/morrisAuthorityContract.test.ts @@ -63,4 +63,16 @@ describe("Morris authority contract", () => { expect(fetcher.mock.calls[1]?.[1]).toMatchObject({ method: "DELETE" }); expect(fetcher.mock.calls[2]?.[1]).toMatchObject({ method: "POST" }); }); + + it("uses the canonical same-origin API prefix by default", async () => { + const fetcher = vi.fn(async () => new Response(JSON.stringify(completed()), { + status: 200, + headers: { "Content-Type": "application/json" }, + })); + const client = createMorrisAuthorityClient({ fetchImpl: fetcher }); + + await client.status("job-1"); + + expect(fetcher.mock.calls[0]?.[0]).toBe("/api/rate-of-closure/v1/morris/jobs/job-1"); + }); }); diff --git a/src/rate_of_closure/web/tsconfig.node.json b/src/rate_of_closure/web/tsconfig.node.json index 42872c59f5..d6337c3314 100644 --- a/src/rate_of_closure/web/tsconfig.node.json +++ b/src/rate_of_closure/web/tsconfig.node.json @@ -6,5 +6,5 @@ "moduleResolution": "bundler", "allowSyntheticDefaultImports": true }, - "include": ["vite.config.ts"] + "include": ["vite.config.ts", "morrisAuthorityProxy.ts"] } diff --git a/src/rate_of_closure/web/vite.config.ts b/src/rate_of_closure/web/vite.config.ts index 169bdd96b5..d75b588949 100644 --- a/src/rate_of_closure/web/vite.config.ts +++ b/src/rate_of_closure/web/vite.config.ts @@ -1,7 +1,11 @@ -import { defineConfig } from "vite"; +import { defineConfig, loadEnv } from "vite"; import react from "@vitejs/plugin-react"; -export default defineConfig({ +import { morrisAuthorityProxy } from "./morrisAuthorityProxy"; + +export default defineConfig(({ mode }) => { + const proxy = morrisAuthorityProxy(loadEnv(mode, process.cwd(), "")); + return { // Relative base so the built bundle works from any static-host subpath // (GitHub Pages project sites included), not just a domain root. base: "./", @@ -17,7 +21,8 @@ export default defineConfig({ server: { port: 5193, strictPort: true, - open: false + open: false, + proxy, }, build: { outDir: "dist", @@ -30,5 +35,6 @@ export default defineConfig({ }, }, }, - } + }, + }; }); diff --git a/src/shared/python/gui_launcher/launcher_web.py b/src/shared/python/gui_launcher/launcher_web.py index 377876514e..5cc347bbca 100644 --- a/src/shared/python/gui_launcher/launcher_web.py +++ b/src/shared/python/gui_launcher/launcher_web.py @@ -190,7 +190,12 @@ def _reap_child(process: subprocess.Popen) -> int: return _SIGINT_EXIT_CODE -def launch_web_from_gui_info(gui_info: dict[str, object], caller_file: str) -> int: +def launch_web_from_gui_info( + gui_info: dict[str, object], + caller_file: str, + *, + env_vars: dict[str, str] | None = None, +) -> int: """Launch a React web app from a ``GUI_INFO`` dict.""" if gui_info is None: raise ValueError("gui_info must be provided") @@ -209,4 +214,5 @@ def launch_web_from_gui_info(gui_info: dict[str, object], caller_file: str) -> i web_dir=web_dir, port=int(port), auto_open_browser=bool(auto_open), + env_vars=env_vars, ) diff --git a/tests/rate_of_closure/test_morris_authority_child.py b/tests/rate_of_closure/test_morris_authority_child.py new file mode 100644 index 0000000000..a66511339f --- /dev/null +++ b/tests/rate_of_closure/test_morris_authority_child.py @@ -0,0 +1,146 @@ +"""Pre-lifespan resource ownership tests for the Morris authority child.""" + +from __future__ import annotations + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from rate_of_closure.application.morris import child +from rate_of_closure.application.morris.router import MorrisJobRegistry +from rate_of_closure.application.morris.service import RateMorrisService + + +class _Socket: + def __init__(self, *, fail_close: bool = False) -> None: + self.close_count = 0 + self.fail_close = fail_close + + def setsockopt(self, *_args: object) -> None: + return None + + def bind(self, _address: object) -> None: + return None + + def listen(self, _backlog: int) -> None: + return None + + def getsockname(self) -> tuple[str, int]: + return ("127.0.0.1", 43210) + + def close(self) -> None: + self.close_count += 1 + if self.fail_close: + raise OSError("listener close failed") + + +class _Registry: + def __init__(self, _service: object, *, fail_close: bool = False) -> None: + self.close_count = 0 + self.fail_close = fail_close + + def close(self) -> None: + self.close_count += 1 + if self.fail_close: + raise OSError("registry close failed") + + +class _SocketModule: + AF_INET = 2 + SOCK_STREAM = 1 + SOL_SOCKET = 0xFFFF + SO_REUSEADDR = 4 + SOMAXCONN = 128 + + def __init__(self, listener: _Socket) -> None: + self._listener = listener + + def socket(self, *_args: object) -> _Socket: + return self._listener + + +def test_child_closes_registry_when_app_setup_fails_before_lifespan( + monkeypatch: pytest.MonkeyPatch, +) -> None: + listener = _Socket() + registries: list[_Registry] = [] + monkeypatch.setenv(child.AUTHORITY_TOKEN_ENV, "private-token") + monkeypatch.setattr(child, "socket", _SocketModule(listener)) + + def registry(service: object) -> _Registry: + item = _Registry(service) + registries.append(item) + return item + + monkeypatch.setattr(child, "MorrisJobRegistry", registry) + + def fail(*_args: object, **_kwargs: object) -> None: + raise RuntimeError("setup failed") + + monkeypatch.setattr(child, "create_morris_authority_app", fail) + + with pytest.raises(RuntimeError, match="setup failed"): + child.main() + assert listener.close_count == 1 + assert registries[0].close_count == 1 + + +@pytest.mark.parametrize("registry_close_fails", [False, True]) +def test_child_preserves_setup_error_and_attempts_all_cleanup_failures( + monkeypatch: pytest.MonkeyPatch, + registry_close_fails: bool, +) -> None: + listener = _Socket(fail_close=True) + registry = _Registry(object(), fail_close=registry_close_fails) + setup_error = RuntimeError("setup failed") + monkeypatch.setenv(child.AUTHORITY_TOKEN_ENV, "private-token") + monkeypatch.setattr(child, "socket", _SocketModule(listener)) + monkeypatch.setattr(child, "MorrisJobRegistry", lambda _service: registry) + + def fail(*_args: object, **_kwargs: object) -> None: + raise setup_error + + monkeypatch.setattr(child, "create_morris_authority_app", fail) + + with pytest.raises(RuntimeError) as caught: + child.main() + assert caught.value is setup_error + assert listener.close_count == 1 + assert registry.close_count == 1 + + +class _CountingRealRegistry(MorrisJobRegistry): + def __init__(self) -> None: + self.close_count = 0 + super().__init__(RateMorrisService()) + + def close(self) -> None: + self.close_count += 1 + super().close() + + +def test_child_transfers_registry_to_normal_asgi_lifespan_exactly_once( + monkeypatch: pytest.MonkeyPatch, +) -> None: + listener = _Socket() + registry = _CountingRealRegistry() + monkeypatch.setenv(child.AUTHORITY_TOKEN_ENV, "private-token") + monkeypatch.setattr(child, "socket", _SocketModule(listener)) + monkeypatch.setattr(child, "MorrisJobRegistry", lambda _service: registry) + monkeypatch.setattr(child.uvicorn, "Config", lambda app, **_kwargs: app) + + class Server: + def __init__(self, app: FastAPI) -> None: + self.app = app + self.should_exit = False + + def run(self, *, sockets: list[object]) -> None: + assert sockets == [listener] + with TestClient(self.app): + pass + + monkeypatch.setattr(child.uvicorn, "Server", Server) + + assert child.main() == 0 + assert listener.close_count == 1 + assert registry.close_count == 1 diff --git a/tests/rate_of_closure/test_morris_authority_host.py b/tests/rate_of_closure/test_morris_authority_host.py new file mode 100644 index 0000000000..a5f993a196 --- /dev/null +++ b/tests/rate_of_closure/test_morris_authority_host.py @@ -0,0 +1,116 @@ +"""Authenticated loopback app contract for the Morris authority host.""" + +from __future__ import annotations + +from fastapi.testclient import TestClient + +from rate_of_closure.application.morris.contracts import ( + MORRIS_JOB_SCHEMA_ID, + MORRIS_REQUEST_SCHEMA_ID, +) +from rate_of_closure.application.morris.host import ( + API_PREFIX, + CAPABILITY_PATH, + create_morris_authority_app, +) +from rate_of_closure.application.morris.router import MorrisJobRegistry +from rate_of_closure.application.morris.service import RateMorrisService + + +class _CountingRegistry(MorrisJobRegistry): + def __init__(self) -> None: + self.close_count = 0 + super().__init__(RateMorrisService()) + + def close(self) -> None: + self.close_count += 1 + super().close() + + +def test_host_requires_exact_bearer_and_owns_registry_lifespan() -> None: + registry = _CountingRegistry() + with TestClient(create_morris_authority_app("secret-token", registry)) as client: + for headers in ( + {}, + {"Authorization": "Bearer wrong"}, + {"Authorization": "Basic secret-token"}, + ): + response = client.get(CAPABILITY_PATH, headers=headers) + assert response.status_code == 401 + assert response.headers["www-authenticate"] == "Bearer" + assert response.headers["cache-control"] == "no-store" + assert response.headers["x-content-type-options"] == "nosniff" + capability = client.get( + CAPABILITY_PATH, headers={"Authorization": "Bearer secret-token"} + ) + assert capability.status_code == 200 + assert capability.json() == { + "schema_id": "rate-of-closure/morris-authority-capability", + "schema_version": 1, + "available": True, + "api_prefix": API_PREFIX, + "request_schema_id": MORRIS_REQUEST_SCHEMA_ID, + "job_schema_id": MORRIS_JOB_SCHEMA_ID, + } + assert capability.headers["cache-control"] == "no-store" + assert capability.headers["x-content-type-options"] == "nosniff" + assert registry.close_count == 0 + assert registry.close_count == 1 + + +def test_shutdown_control_is_authenticated_by_global_middleware() -> None: + registry = _CountingRegistry() + shutdowns: list[str] = [] + app = create_morris_authority_app( + "secret-token", registry, lambda: shutdowns.append("requested") + ) + with TestClient(app) as client: + unauthorized = client.post("/_control/shutdown") + assert unauthorized.status_code == 401 + assert shutdowns == [] + authorized = client.post( + "/_control/shutdown", + headers={"Authorization": "Bearer secret-token"}, + ) + assert authorized.status_code == 200 + assert authorized.json() == {"status": "stopping"} + assert shutdowns == ["requested"] + assert registry.close_count == 1 + + +def test_host_does_not_enable_cors() -> None: + registry = _CountingRegistry() + app = create_morris_authority_app("secret-token", registry) + with TestClient(app): + assert all( + "cors" not in type(item).__name__.lower() for item in app.user_middleware + ) + assert registry.close_count == 1 + + +def test_authenticated_error_responses_retain_security_headers() -> None: + registry = _CountingRegistry() + app = create_morris_authority_app("secret-token", registry) + + @app.get("/_test/validated") + async def validated(required: int) -> dict[str, int]: + return {"required": required} + + @app.get("/_test/explode") + async def explode() -> None: + raise RuntimeError("private detail") + + headers = {"Authorization": "Bearer secret-token"} + with TestClient(app, raise_server_exceptions=False) as client: + responses = ( + client.get("/not-found", headers=headers), + client.get("/_test/validated", headers=headers), + client.get("/_test/explode", headers=headers), + ) + assert [response.status_code for response in responses] == [404, 422, 500] + for response in responses: + assert response.headers["cache-control"] == "no-store" + assert response.headers["x-content-type-options"] == "nosniff" + assert responses[2].json() == {"error": "internal server error"} + assert "private detail" not in responses[2].text + assert registry.close_count == 1 diff --git a/tests/rate_of_closure/test_morris_authority_runtime.py b/tests/rate_of_closure/test_morris_authority_runtime.py new file mode 100644 index 0000000000..c771d8ac01 --- /dev/null +++ b/tests/rate_of_closure/test_morris_authority_runtime.py @@ -0,0 +1,146 @@ +"""Real child-process lifecycle smoke for the Morris authority runtime.""" + +from __future__ import annotations + +import http.client +import io +from pathlib import Path + +import pytest + +from rate_of_closure.application.morris import runtime as authority_runtime +from rate_of_closure.application.morris.host import CAPABILITY_PATH +from rate_of_closure.application.morris.runtime import MorrisAuthorityRuntime + + +@pytest.mark.integration +def test_runtime_starts_authenticated_loopback_child_and_reaps_it() -> None: + source_root = Path(__file__).resolve().parents[2] / "src" + runtime = MorrisAuthorityRuntime.start(source_root, startup_timeout_s=15.0) + process = runtime.process + try: + assert runtime.base_url.startswith("http://127.0.0.1:") + assert runtime.vite_env == { + "ROC_MORRIS_AUTHORITY_URL": runtime.base_url, + "ROC_MORRIS_AUTHORITY_TOKEN": runtime.token, + } + assert runtime.token not in repr(runtime) + port = int(runtime.base_url.rsplit(":", 1)[1]) + connection = http.client.HTTPConnection("127.0.0.1", port, timeout=2.0) + connection.request("GET", CAPABILITY_PATH) + unauthorized = connection.getresponse() + unauthorized.read() + assert unauthorized.status == 401 + assert unauthorized.getheader("WWW-Authenticate") == "Bearer" + connection.close() + finally: + runtime.close() + assert process.poll() is not None + assert process.returncode == 0 + assert process.stdout is not None + assert process.stdout.closed + + +def test_runtime_rejects_unbounded_startup_timeout() -> None: + with pytest.raises(ValueError, match="startup_timeout_s"): + MorrisAuthorityRuntime.start(startup_timeout_s=0.0) + + +def test_runtime_fails_closed_when_interpreter_path_is_missing( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr(authority_runtime.sys, "executable", str(tmp_path / "missing")) + with pytest.raises(RuntimeError, match="interpreter path"): + MorrisAuthorityRuntime.start(startup_timeout_s=1.0) + + +class _CountingPipe(io.StringIO): + def __init__(self, fail_close: bool = False) -> None: + super().__init__() + self.close_count = 0 + self.fail_close = fail_close + + def close(self) -> None: + self.close_count += 1 + if self.fail_close: + self.fail_close = False + raise OSError("pipe close failed") + super().close() + + +class _FakeProcess: + def __init__( + self, *, fail_terminate: bool = False, fail_close: bool = False + ) -> None: + self.stdout = _CountingPipe(fail_close) + self.returncode: int | None = None + self.terminate_count = 0 + self.kill_count = 0 + self.fail_terminate = fail_terminate + + def poll(self) -> int | None: + return self.returncode + + def terminate(self) -> None: + self.terminate_count += 1 + if self.fail_terminate: + raise OSError("terminate failed") + self.returncode = -15 + + def kill(self) -> None: + self.kill_count += 1 + self.returncode = -9 + + def wait(self, timeout: float | None = None) -> int: + del timeout + assert self.returncode is not None + return self.returncode + + +@pytest.mark.parametrize("interruption", [KeyboardInterrupt(), SystemExit(7)]) +def test_start_reaps_child_and_closes_pipe_on_base_exception( + monkeypatch: pytest.MonkeyPatch, + interruption: BaseException, +) -> None: + process = _FakeProcess() + monkeypatch.setattr(authority_runtime, "_spawn_child", lambda *_args: process) + + def interrupt(*_args: object) -> int: + raise interruption + + monkeypatch.setattr(authority_runtime, "_ready_port", interrupt) + with pytest.raises(type(interruption)) as caught: + MorrisAuthorityRuntime.start(startup_timeout_s=1.0) + assert caught.value is interruption + assert process.terminate_count == 1 + assert process.kill_count == 0 + assert process.stdout.close_count == 1 + + +def test_runtime_close_is_idempotent_and_closes_completed_child_pipe() -> None: + process = _FakeProcess() + process.returncode = 0 + runtime = MorrisAuthorityRuntime(process, "http://127.0.0.1:1234", "private-token") # type: ignore[arg-type] + + runtime.close() + runtime.close() + + assert process.stdout.close_count == 1 + + +def test_start_preserves_original_interrupt_when_cleanup_itself_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + process = _FakeProcess(fail_terminate=True, fail_close=True) + interruption = KeyboardInterrupt() + monkeypatch.setattr(authority_runtime, "_spawn_child", lambda *_args: process) + + def interrupt(*_args: object) -> int: + raise interruption + + monkeypatch.setattr(authority_runtime, "_ready_port", interrupt) + with pytest.raises(KeyboardInterrupt) as caught: + MorrisAuthorityRuntime.start(startup_timeout_s=1.0) + assert caught.value is interruption + assert process.terminate_count == 1 + assert process.stdout.close_count == 1 diff --git a/tests/rate_of_closure/test_rate_launch_web_authority.py b/tests/rate_of_closure/test_rate_launch_web_authority.py new file mode 100644 index 0000000000..09d4a21a48 --- /dev/null +++ b/tests/rate_of_closure/test_rate_launch_web_authority.py @@ -0,0 +1,63 @@ +"""Rate web launcher owns authority lifetime around the Vite process.""" + +from __future__ import annotations + +import pytest + +from rate_of_closure import launch_web + + +class _Runtime: + vite_env = {"ROC_MORRIS_AUTHORITY_URL": "http://127.0.0.1:1"} + + def __init__(self, events: list[str]) -> None: + self.events = events + + def __enter__(self) -> _Runtime: + self.events.append("authority-enter") + return self + + def __exit__(self, *_args: object) -> None: + self.events.append("authority-exit") + + +def test_launch_web_owns_authority_around_vite( + monkeypatch: pytest.MonkeyPatch, +) -> None: + events: list[str] = [] + monkeypatch.setattr( + launch_web.MorrisAuthorityRuntime, + "start", + lambda: _Runtime(events), + ) + + def launch(_info: object, _file: str, *, env_vars: dict[str, str]) -> int: + events.append("vite") + assert env_vars == _Runtime.vite_env + return 23 + + monkeypatch.setattr(launch_web, "launch_web_from_gui_info", launch) + assert launch_web.main() == 23 + assert events == ["authority-enter", "vite", "authority-exit"] + + +@pytest.mark.parametrize("failure", [OSError("spawn failed"), KeyboardInterrupt()]) +def test_launch_web_closes_authority_when_vite_launch_raises( + monkeypatch: pytest.MonkeyPatch, + failure: BaseException, +) -> None: + events: list[str] = [] + monkeypatch.setattr( + launch_web.MorrisAuthorityRuntime, + "start", + lambda: _Runtime(events), + ) + + def launch(_info: object, _file: str, *, env_vars: dict[str, str]) -> int: + del env_vars + raise failure + + monkeypatch.setattr(launch_web, "launch_web_from_gui_info", launch) + with pytest.raises(type(failure)): + launch_web.main() + assert events == ["authority-enter", "authority-exit"] diff --git a/tests/test_gui_launcher_web.py b/tests/test_gui_launcher_web.py index 72238c47d0..34f4cbc4b2 100644 --- a/tests/test_gui_launcher_web.py +++ b/tests/test_gui_launcher_web.py @@ -142,3 +142,22 @@ def test_open_browser_later_opens_browser_when_ready( launcher_web._open_browser_later(5173) assert opened == ["http://localhost:5173"] + + +def test_gui_info_launcher_forwards_keyword_only_environment( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + captured: dict[str, object] = {} + monkeypatch.setattr( + launcher_web, + "launch_web_app", + lambda **kwargs: captured.update(kwargs) or 0, + ) + caller = tmp_path / "launch_web.py" + result = launcher_web.launch_web_from_gui_info( + {"name": "Demo", "web": {"path": "web"}}, + str(caller), + env_vars={"SERVER_ONLY": "secret"}, + ) + assert result == 0 + assert captured["env_vars"] == {"SERVER_ONLY": "secret"} diff --git a/uv.lock b/uv.lock index 6a742b3894..edb01f744c 100644 --- a/uv.lock +++ b/uv.lock @@ -3975,6 +3975,7 @@ ai = [ { name = "torch" }, ] all = [ + { name = "fastapi" }, { name = "matplotlib" }, { name = "mujoco" }, { name = "pandas" }, @@ -3982,6 +3983,7 @@ all = [ { name = "scipy" }, { name = "sympy" }, { name = "trimesh" }, + { name = "uvicorn" }, ] cad = [ { name = "build123d" }, @@ -4045,6 +4047,11 @@ process = [ { name = "pandas" }, { name = "scipy" }, ] +rate-morris-authority = [ + { name = "fastapi" }, + { name = "scipy" }, + { name = "uvicorn" }, +] robotics = [ { name = "matplotlib" }, { name = "numpy" }, @@ -4068,6 +4075,7 @@ test = [ { name = "python-multipart" }, { name = "requests" }, { name = "sqlmodel" }, + { name = "uvicorn" }, ] theme = [ { name = "pyqt6" }, @@ -4087,7 +4095,9 @@ requires-dist = [ { name = "defusedxml", specifier = ">=0.7.0" }, { name = "ezdxf", extras = ["draw"], marker = "extra == 'pid'", specifier = ">=1.4.3" }, { name = "ezdxf", extras = ["draw"], marker = "extra == 'test'", specifier = ">=1.4.3" }, + { name = "fastapi", marker = "extra == 'all'", specifier = ">=0.100.0" }, { name = "fastapi", marker = "extra == 'chat'", specifier = ">=0.100.0" }, + { name = "fastapi", marker = "extra == 'rate-morris-authority'", specifier = ">=0.100.0" }, { name = "fastapi", marker = "extra == 'test'", specifier = ">=0.100.0" }, { name = "httpx", marker = "extra == 'test'", specifier = ">=0.25.0" }, { name = "hypothesis", marker = "extra == 'dev'", specifier = ">=6.0.0" }, @@ -4140,6 +4150,7 @@ requires-dist = [ { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" }, { name = "scipy", marker = "extra == 'all'", specifier = ">=1.10.0,<1.18" }, { name = "scipy", marker = "extra == 'process'", specifier = ">=1.10.0,<1.18" }, + { name = "scipy", marker = "extra == 'rate-morris-authority'", specifier = ">=1.10.0,<1.18" }, { name = "scipy", marker = "extra == 'signal'", specifier = ">=1.10.0,<1.18" }, { name = "scipy", marker = "extra == 'urdf'", specifier = ">=1.10.0,<1.18" }, { name = "speechrecognition", marker = "extra == 'chat-voice'", specifier = ">=3.10.0" }, @@ -4156,10 +4167,13 @@ requires-dist = [ { name = "tree-sitter-typescript", marker = "extra == 'codemap'", specifier = ">=0.21" }, { name = "trimesh", marker = "extra == 'all'", specifier = ">=4.0.0" }, { name = "trimesh", marker = "extra == 'urdf'", specifier = ">=4.0.0" }, + { name = "uvicorn", marker = "extra == 'all'", specifier = ">=0.30.0" }, + { name = "uvicorn", marker = "extra == 'rate-morris-authority'", specifier = ">=0.30.0" }, + { name = "uvicorn", marker = "extra == 'test'", specifier = ">=0.30.0" }, { name = "vcrpy", marker = "extra == 'dev'", specifier = ">=6.0.0" }, { name = "watchdog", marker = "extra == 'codemap'", specifier = ">=3.0.0" }, ] -provides-extras = ["all", "ai", "urdf", "signal", "process", "robotics", "gui", "chat", "chat-voice", "theme", "pid", "terminal", "cad", "dwsim", "codemap", "codemap-mcp", "jupyter", "video-analyzer", "test", "dev"] +provides-extras = ["rate-morris-authority", "all", "ai", "urdf", "signal", "process", "robotics", "gui", "chat", "chat-voice", "theme", "pid", "terminal", "cad", "dwsim", "codemap", "codemap-mcp", "jupyter", "video-analyzer", "test", "dev"] [[package]] name = "urllib3" @@ -4175,8 +4189,8 @@ name = "uvicorn" version = "0.47.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click", marker = "sys_platform != 'emscripten'" }, - { name = "h11", marker = "sys_platform != 'emscripten'" }, + { name = "click" }, + { name = "h11" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f6/b1/8e7077a8641086aea449e1b5752a570f1b5906c64e0a33cd6d93b63a066b/uvicorn-0.47.0.tar.gz", hash = "sha256:7c9a0ea1a9414106bbab7324609c162d8fa0cdcdcb703060987269d77c7bb533", size = 90582, upload-time = "2026-05-14T18:16:54.455Z" } wheels = [