From 5b9812eec94f92ede5ecf19e601b7cc9e79a8a6c Mon Sep 17 00:00:00 2001 From: thinkinginmath Date: Sat, 19 Sep 2026 04:40:35 +0000 Subject: [PATCH] Serve HTTPS on loopback so Safari can reach the runtime from HTTPS pages Safari blocks an HTTPS page from calling http://127.0.0.1 as mixed content; Chrome and Firefox allow it. Measured on macOS 2026-09-19 with a test page on a public HTTPS origin: Chrome 152 and Firefox 155 passed against http://127.0.0.1:9477, Safari 26.6 failed ("Load failed"), and Safari passed against https://127.0.0.1:9478 with a trusted self-signed loopback certificate. - `agent-runtime tls setup` creates that certificate in /tls (key 0600, dir 0700): RSA 2048, SAN 127.0.0.1 / ::1 / localhost, serverAuth only, CA:FALSE, 397 days. On macOS it adds it to the login keychain trusted for SSL only (macOS asks for the password). It reuses a valid certificate and renews one within 30 days of expiry. `tls status` shows expiry and trust; `tls remove` untrusts and deletes it. - `agent-runtime serve` also listens on https://127.0.0.1:9478 (--https-port, --no-https, AGENT_RUNTIME_HTTPS_PORT) when the certificate exists. Both listeners serve the same app, so pairing and tokens are shared and enforced alike. One signal handler stops both: uvicorn servers each install their own, and the last one would win. It falls back to HTTP only if the port is taken or the certificate expired, and says why. --debug keeps the single auto-reload listener. - Docs: the `targetAddressSpace: "local"` hint is removed. Chrome 151 rejects it for 127.0.0.1, which is in the loopback address space, and no annotation is needed. integration.md has the measured browser table and an HTTPS-then-HTTP detection example; security.md covers the certificate; the installer mentions `tls setup`. - New dependency: cryptography>=42. Tests: certificate properties, reuse and renewal, the macOS keychain commands (mocked), the CLI, and an end-to-end `serve` run. It checks HTTP, verified HTTPS for 127.0.0.1 and localhost, and pairing enforced on HTTPS, and that one SIGINT stops both listeners. 132 passed; ruff, format and mypy clean. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 2 + README.md | 27 ++++- docs/integration.md | 58 +++++++--- docs/security.md | 21 ++++ pyproject.toml | 1 + scripts/install-macos.sh | 3 +- src/agent_runtime/cli.py | 140 ++++++++++++++++++++++- src/agent_runtime/config.py | 15 +++ src/agent_runtime/serving.py | 97 ++++++++++++++++ src/agent_runtime/tls.py | 208 +++++++++++++++++++++++++++++++++++ tests/test_config.py | 1 + tests/test_serving.py | 116 +++++++++++++++++++ tests/test_tls.py | 175 +++++++++++++++++++++++++++++ 13 files changed, 837 insertions(+), 27 deletions(-) create mode 100644 src/agent_runtime/serving.py create mode 100644 src/agent_runtime/tls.py create mode 100644 tests/test_serving.py create mode 100644 tests/test_tls.py diff --git a/CLAUDE.md b/CLAUDE.md index 5ae019d..e32c18a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,6 +23,8 @@ agent-runtime/ │ ├── events.py # Observability event system │ ├── observability.py # Metrics and logging │ ├── cli.py # CLI entry point +│ ├── serving.py # HTTP + HTTPS listeners with one shutdown +│ ├── tls.py # Loopback certificate and macOS keychain trust (Safari) │ ├── api/ │ │ ├── health.py # Health check endpoints │ │ ├── kernel.py # Kernel lifecycle endpoints diff --git a/README.md b/README.md index 9dcc2f1..e387ab4 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,22 @@ python -m agent_runtime.server The runtime will start on `http://localhost:9477` by default. +### HTTPS for Safari + +Safari blocks HTTPS pages from calling `http://127.0.0.1`, so a course page on +an HTTPS site cannot reach the runtime there. Run this once: + +```bash +agent-runtime tls setup +``` + +It creates a certificate valid only for 127.0.0.1, ::1 and localhost (not a +certificate authority, 397 days) and, on macOS, trusts it for SSL in your login +keychain, which asks for your password. From then on `agent-runtime serve` also +listens on `https://127.0.0.1:9478`. `agent-runtime tls status` shows the +certificate and its expiry; `tls setup` renews it; `tls remove` untrusts and +deletes it. Chrome and Firefox reach `http://127.0.0.1:9477` without this step. + ### Pairing Loopback web apps (`localhost`, `127.0.0.1`, or `::1`, on any port) are trusted @@ -105,14 +121,14 @@ agent-runtime serve ### JavaScript Example ```javascript -const RUNTIME_URL = 'http://127.0.0.1:9477'; +// HTTPS (after `agent-runtime tls setup`) is the only address Safari can reach from +// an HTTPS page; Chrome and Firefox reach both. No targetAddressSpace option is +// needed: 127.0.0.1 is loopback, and Chrome rejects the "local" annotation for it. +const RUNTIME_URL = 'https://127.0.0.1:9478'; // or 'http://127.0.0.1:9477' // Required when this page is served from a non-loopback origin. async function pair() { - const res = await fetch(`${RUNTIME_URL}/pairing/request`, { - method: 'POST', - targetAddressSpace: 'local' - }); + const res = await fetch(`${RUNTIME_URL}/pairing/request`, { method: 'POST' }); if (!res.ok) throw new Error((await res.json()).detail); return (await res.json()).token; } @@ -200,6 +216,7 @@ await executeStreaming('my-session', 'for i in range(5): print(i)', (output) => |----------|---------|-------------| | `AGENT_RUNTIME_HOST` | `127.0.0.1` | Server bind address | | `AGENT_RUNTIME_PORT` | `9477` | Server port | +| `AGENT_RUNTIME_HTTPS_PORT` | `9478` | HTTPS port, used once `agent-runtime tls setup` has run | | `AGENT_RUNTIME_REQUIRE_PAIRING` | `true` | Require origin approval | | `AGENT_RUNTIME_DEBUG` | `false` | Enable debug mode with hot reload | diff --git a/docs/integration.md b/docs/integration.md index f86e64a..46f667e 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -1,12 +1,15 @@ # Browser Integration -Agent Runtime exposes a vendor-neutral HTTP API at `http://127.0.0.1:9477`. -It is intended for browser-based courses and labs that execute Python on the -learner's own computer. +Agent Runtime exposes a vendor-neutral HTTP API at `http://127.0.0.1:9477` and, +once `agent-runtime tls setup` has run, the same API over HTTPS at +`https://127.0.0.1:9478`. It is intended for browser-based courses and labs +that execute Python on the learner's own computer. ## Connection flow -1. Call `GET /health` to detect the local runtime. +1. Call `GET /health` to detect the local runtime: `https://127.0.0.1:9478` + first, then `http://127.0.0.1:9477`. Safari reaches only the HTTPS address + (see below); Chrome and Firefox reach both. 2. If the page has a loopback origin, call runtime endpoints directly. 3. Otherwise, call `POST /pairing/request` and tell the learner to review the approval prompt in the terminal that started Agent Runtime. @@ -19,20 +22,27 @@ The browser cannot select the origin being approved. The runtime reads the browser-controlled `Origin` header and binds the token to that exact value. ```javascript -const runtimeUrl = "http://127.0.0.1:9477"; +// HTTPS first (the only one Safari allows from an HTTPS page), then HTTP. +async function findRuntime() { + for (const url of ["https://127.0.0.1:9478", "http://127.0.0.1:9477"]) { + try { + if ((await fetch(`${url}/health`)).ok) return url; + } catch { + // not listening, blocked by the browser, or the certificate is not trusted + } + } + return null; +} -async function pairRuntime() { - const response = await fetch(`${runtimeUrl}/pairing/request`, { - method: "POST", - targetAddressSpace: "local", - }); +async function pairRuntime(runtimeUrl) { + const response = await fetch(`${runtimeUrl}/pairing/request`, { method: "POST" }); if (!response.ok) { throw new Error((await response.json()).detail); } return (await response.json()).token; } -async function runCell(token, labId, code) { +async function runCell(runtimeUrl, token, labId, code) { const response = await fetch(`${runtimeUrl}/cell/run`, { method: "POST", headers: { @@ -50,10 +60,28 @@ async function runCell(token, labId, code) { ## Browser security behavior -Chrome 142 and later asks the learner for Local Network Access permission when -a public site first contacts loopback. The `targetAddressSpace: "local"` -annotation declares that intent. Other browsers may still use Private Network -Access preflights, which the runtime answers for compatibility. +Measured on macOS, 2026-09-19, from an HTTPS page on a public origin: + +| Browser | `http://127.0.0.1:9477` | `https://127.0.0.1:9478` (after `tls setup`) | +|---|---|---| +| Chrome 152 | works after the Local Network Access prompt | works | +| Firefox 155 | works | works | +| Safari 26.6 | blocked as mixed content (`TypeError: Load failed`) | works | + +- **Chrome** (142 and later) asks the learner for Local Network Access + permission the first time a public site contacts loopback. If the learner + declines, requests fail with a CORS "Permission was denied" error. Do not pass + `targetAddressSpace: "local"`: `127.0.0.1` is in the `loopback` address + space, and Chrome 151 rejects the mismatch ("Request had a target IP address + space of `local` yet the resource is in address space `loopback`"). No + annotation is needed. +- **Safari** treats `http://127.0.0.1` as insecure content and blocks it from + HTTPS pages. `agent-runtime tls setup` creates a certificate for 127.0.0.1, + ::1 and localhost and trusts it in the login keychain; `agent-runtime serve` + then also listens on `https://127.0.0.1:9478`. +- Browsers that still use Private Network Access preflights get the + `Access-Control-Allow-Private-Network` response the runtime sends for + compatibility. CORS enables transport; it does not grant execution authority. Protected endpoints still require either a loopback page origin or a bearer token issued diff --git a/docs/security.md b/docs/security.md index ad47b24..d52ce20 100644 --- a/docs/security.md +++ b/docs/security.md @@ -137,6 +137,24 @@ class ExecuteRequest(BaseModel): cell_id: Optional[str] = None ``` +### 7. Loopback TLS Certificate + +`agent-runtime tls setup` lets Safari reach the runtime from HTTPS pages (it +blocks `http://127.0.0.1` there as mixed content). The certificate is: + +- self-signed and generated on the machine; the private key stays in + `/tls/` (directory `0700`, key `0600`) and is never sent anywhere; +- valid only for `127.0.0.1`, `::1` and `localhost`, for server authentication; +- not a certificate authority (`basicConstraints CA:FALSE`, no `keyCertSign`), + so trusting it cannot make the machine accept a certificate for any other + name, even if the key were stolen; +- valid for 397 days; `tls setup` renews it within 30 days of expiry. + +On macOS it is added to the login keychain with SSL trust only. The HTTPS +listener serves the same app as the HTTP one: pairing and bearer tokens apply +unchanged. `agent-runtime tls remove` removes the trust setting, the keychain +entry and the files. + ## Known Limitations ### 1. No Code Sandboxing @@ -203,6 +221,9 @@ agent-runtime pairing revoke https://suspicious-origin.com # Nuclear option - delete all pairing data rm ~/.agent-runtime/paired_origins.json + +# Untrust and delete the loopback HTTPS certificate +agent-runtime tls remove ``` ### Cleaning Up Environments diff --git a/pyproject.toml b/pyproject.toml index e5ce0d9..94c5b8e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ dependencies = [ "pydantic>=2.5.0", "pydantic-settings>=2.1.0", "httpx>=0.26.0", + "cryptography>=42.0.0", ] [project.optional-dependencies] diff --git a/scripts/install-macos.sh b/scripts/install-macos.sh index 0c644cf..549cc69 100755 --- a/scripts/install-macos.sh +++ b/scripts/install-macos.sh @@ -196,8 +196,9 @@ print_success() { echo "Quick start:" echo " ${BLUE}agent-runtime serve${NC} # Start the runtime" echo " ${BLUE}agent-runtime serve --no-pairing${NC} # Start without pairing (dev mode)" + echo " ${BLUE}agent-runtime tls setup${NC} # Once: HTTPS on 127.0.0.1:9478, needed by Safari" echo "" - echo "Runtime will be available at: http://localhost:9477" + echo "Runtime will be available at: http://localhost:9477 (and https://127.0.0.1:9478 after tls setup)" echo "" echo "For more information:" echo " ${BLUE}agent-runtime --help${NC}" diff --git a/src/agent_runtime/cli.py b/src/agent_runtime/cli.py index eb2e0c8..dbf9fb1 100644 --- a/src/agent_runtime/cli.py +++ b/src/agent_runtime/cli.py @@ -45,6 +45,32 @@ def main() -> None: action="store_true", help="Disable pairing requirement (for development)", ) + serve_parser.add_argument( + "--https-port", + type=int, + default=settings.https_port, + help=f"HTTPS port, used once 'tls setup' has run (default: {settings.https_port})", + ) + serve_parser.add_argument( + "--no-https", + action="store_true", + help="Serve HTTP only, even when a TLS certificate exists", + ) + + # tls commands + tls_parser = subparsers.add_parser( + "tls", help="Manage the loopback HTTPS certificate (needed for Safari)" + ) + tls_subparsers = tls_parser.add_subparsers(dest="tls_command") + tls_setup = tls_subparsers.add_parser( + "setup", help="Create the certificate and, on macOS, trust it in the login keychain" + ) + tls_setup.add_argument("--force", action="store_true", help="Replace an existing certificate") + tls_setup.add_argument( + "--no-trust", action="store_true", help="Create the certificate without trusting it" + ) + tls_subparsers.add_parser("status", help="Show the certificate, its expiry and trust") + tls_subparsers.add_parser("remove", help="Untrust and delete the certificate") # env commands env_parser = subparsers.add_parser("env", help="Manage environments") @@ -75,15 +101,20 @@ def main() -> None: run_env(args) elif args.command == "pairing": run_pairing(args) + elif args.command == "tls": + run_tls(args) else: parser.print_help() sys.exit(1) def run_serve(args: argparse.Namespace) -> None: - """Run the server.""" + """Run the server: HTTP, plus HTTPS on loopback once `tls setup` has created a certificate.""" + import asyncio + import uvicorn + from agent_runtime import tls from agent_runtime.config import settings as cfg setup_logging(structured=False) @@ -92,17 +123,114 @@ def run_serve(args: argparse.Namespace) -> None: if args.no_pairing: cfg.require_pairing = False + cert = None if args.no_https or args.debug else tls.load(cfg.tls_cert_file, cfg.tls_key_file) + https_note = "" + if cert and _port_in_use(args.host, args.https_port): + https_note = f"HTTPS: port {args.https_port} is in use, serving HTTP only" + cert = None + elif cert and cert.expires_within(0): + https_note = "HTTPS: the certificate has expired; run 'agent-runtime tls setup'" + cert = None + elif cert and cert.expires_within(tls.RENEW_BEFORE_DAYS): + https_note = "HTTPS: the certificate expires soon; 'agent-runtime tls setup' renews it" + elif not cert and not args.no_https and tls.trust_supported(): + https_note = "HTTPS: off. Safari needs it; run 'agent-runtime tls setup' once to enable it" + print(f"Starting Agent Runtime on http://{args.host}:{args.port}") + if cert: + print(f" and https://{args.host}:{args.https_port}") + if https_note: + print(https_note) print(f"Pairing required: {cfg.require_pairing}") print(f"Debug mode: {args.debug}") print() - uvicorn.run( - "agent_runtime.server:app", - host=args.host, - port=args.port, - reload=args.debug, + if args.debug: + # Auto-reload needs uvicorn's own runner (an import string, one listener). + uvicorn.run("agent_runtime.server:app", host=args.host, port=args.port, reload=True) + return + + from agent_runtime.server import app + from agent_runtime.serving import build_listeners, serve_all + + listeners = build_listeners( + app, + args.host, + args.port, + https_port=args.https_port if cert else None, + certfile=str(cert.cert_path) if cert else None, + keyfile=str(cert.key_path) if cert else None, ) + asyncio.run(serve_all(listeners)) + + +def _port_in_use(host: str, port: int) -> bool: + import socket + + with socket.socket() as sock: + sock.settimeout(0.3) + return sock.connect_ex((host, port)) == 0 + + +def run_tls(args: argparse.Namespace) -> None: + """Handle tls commands.""" + from agent_runtime import tls + from agent_runtime.config import settings as cfg + + cert_path, key_path = cfg.tls_cert_file, cfg.tls_key_file + + if args.tls_command == "setup": + info, created = tls.ensure(cert_path, key_path, force=args.force) + print(f"{'Created' if created else 'Using'} certificate {info.cert_path}") + print(f" valid for 127.0.0.1, ::1 and localhost until {info.not_after:%Y-%m-%d}") + print(f" SHA-256 {info.sha256}") + if args.no_trust: + print("Not trusted (--no-trust).") + elif not tls.trust_supported(): + print( + "No trust step needed on this platform: Chrome and Firefox reach " + f"http://127.0.0.1:{cfg.port} directly. Safari, which needs HTTPS, is macOS only." + ) + elif not created and tls.is_trusted(info): + print("Already trusted in the login keychain.") + else: + print("Adding it to the login keychain, trusted for SSL only.") + print("macOS asks for your password.") + try: + tls.trust(info) + except tls.TLSError as error: + print(f"Could not trust the certificate: {error}") + sys.exit(1) + print("Trusted.") + print( + f"Restart 'agent-runtime serve'; it also listens on https://127.0.0.1:{cfg.https_port}." + ) + + elif args.tls_command == "status": + found = tls.load(cert_path, key_path) + if found is None: + print("No certificate. Run 'agent-runtime tls setup' to enable HTTPS for Safari.") + return + trusted = tls.is_trusted(found) + state = {True: "trusted", False: "NOT trusted", None: "trust not checked on this platform"} + print(f"Certificate: {found.cert_path}") + renew = " (renew soon)" if found.expires_within(tls.RENEW_BEFORE_DAYS) else "" + print(f" expires {found.not_after:%Y-%m-%d}{renew}") + print(f" SHA-256 {found.sha256}") + print(f" {state[trusted]}") + + elif args.tls_command == "remove": + found = tls.load(cert_path, key_path) + if found is None: + print("No certificate to remove.") + return + tls.untrust(found) + tls.remove_files(found) + print("Removed the certificate and its keychain trust. The runtime serves HTTP only.") + + else: + print("Usage: agent-runtime tls [setup|status|remove]") + sys.exit(1) def run_env(args: argparse.Namespace) -> None: diff --git a/src/agent_runtime/config.py b/src/agent_runtime/config.py index ba30c52..4d0e134 100644 --- a/src/agent_runtime/config.py +++ b/src/agent_runtime/config.py @@ -29,6 +29,8 @@ class Settings(BaseSettings): # Server settings host: str = "127.0.0.1" port: int = 9477 + # Second listener, HTTPS on loopback, for Safari (see tls.py). Used once `tls setup` has run. + https_port: int = 9478 debug: bool = False # Runtime data directory @@ -56,6 +58,19 @@ def config_file(self) -> Path: """Path to the config file.""" return self.runtime_dir / "config.toml" + @property + def tls_dir(self) -> Path: + """Directory for the loopback TLS certificate and its private key.""" + return self.runtime_dir / "tls" + + @property + def tls_cert_file(self) -> Path: + return self.tls_dir / "localhost.pem" + + @property + def tls_key_file(self) -> Path: + return self.tls_dir / "localhost-key.pem" + @property def paired_origins_file(self) -> Path: """Path to the paired origins file.""" diff --git a/src/agent_runtime/serving.py b/src/agent_runtime/serving.py new file mode 100644 index 0000000..2b3ebfb --- /dev/null +++ b/src/agent_runtime/serving.py @@ -0,0 +1,97 @@ +"""Run the app on one or two loopback listeners: HTTP always, HTTPS when a certificate exists. + +Both listeners serve the same app object, so pairing state and kernels are shared. uvicorn +servers each install their own signal handlers, and the last one installed would win, leaving +the other running after Ctrl-C. So the servers here skip that, and one handler stops both. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import signal +from collections.abc import Generator, Sequence +from dataclasses import dataclass + +import uvicorn +from starlette.types import ASGIApp + + +class _SharedShutdownServer(uvicorn.Server): + """A uvicorn server whose shutdown is driven by `serve_all`'s signal handler.""" + + @contextlib.contextmanager + def capture_signals(self) -> Generator[None, None, None]: # uvicorn >= 0.29 + yield + + def install_signal_handlers(self) -> None: # uvicorn < 0.29 + return None + + +@dataclass(frozen=True) +class Listener: + url: str + server: uvicorn.Server + + +def build_listeners( + app: ASGIApp, + host: str, + port: int, + https_port: int | None = None, + certfile: str | None = None, + keyfile: str | None = None, + log_level: str = "info", +) -> list[Listener]: + """HTTP on `port`, plus HTTPS on `https_port` when a certificate and key are given.""" + listeners = [ + Listener( + url=f"http://{host}:{port}", + server=_SharedShutdownServer( + uvicorn.Config(app, host=host, port=port, log_level=log_level) + ), + ) + ] + if https_port is not None and certfile and keyfile: + https = uvicorn.Config( + app, + host=host, + port=https_port, + ssl_certfile=certfile, + ssl_keyfile=keyfile, + log_level=log_level, + # One lifespan per process: the HTTP listener starts and stops the kernels. + lifespan="off", + ) + listeners.append( + Listener(url=f"https://{host}:{https_port}", server=_SharedShutdownServer(https)) + ) + return listeners + + +def request_shutdown(listeners: Sequence[Listener]) -> None: + """First call: finish in-flight requests and stop. Second call: stop now.""" + for listener in listeners: + if listener.server.should_exit: + listener.server.force_exit = True + listener.server.should_exit = True + + +async def serve_all(listeners: Sequence[Listener]) -> None: + """Serve every listener until SIGINT or SIGTERM; a failure in one stops the others.""" + loop = asyncio.get_running_loop() + for sig in (signal.SIGINT, signal.SIGTERM): + # Not available on Windows or outside the main thread; Ctrl-C then stops the process. + with contextlib.suppress(NotImplementedError, RuntimeError): + loop.add_signal_handler(sig, request_shutdown, listeners) + tasks = [asyncio.ensure_future(listener.server.serve()) for listener in listeners] + try: + done, _ = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) + request_shutdown(listeners) + await asyncio.gather(*tasks, return_exceptions=True) + for task in done: + task.result() + finally: + for sig in (signal.SIGINT, signal.SIGTERM): + with contextlib.suppress(NotImplementedError, RuntimeError, ValueError): + loop.remove_signal_handler(sig) diff --git a/src/agent_runtime/tls.py b/src/agent_runtime/tls.py new file mode 100644 index 0000000..8ee8db6 --- /dev/null +++ b/src/agent_runtime/tls.py @@ -0,0 +1,208 @@ +"""A loopback-only TLS certificate, so pages served over HTTPS can reach the runtime in Safari. + +Chrome and Firefox let an HTTPS page call http://127.0.0.1 because they treat loopback as +potentially trustworthy. Safari does not: it blocks the request as mixed content. Serving the +runtime over HTTPS on loopback, with a certificate the user's machine trusts, removes the mixed +content. Measured on macOS 2026-09-19: Safari 26.6 failed against http://127.0.0.1:9477 ("Load +failed") and passed against https://127.0.0.1:9478 with a certificate of this shape. + +The certificate is deliberately narrow: + +- self-signed, generated on this machine; the private key never leaves the runtime directory + and is readable by the owner only; +- valid for 127.0.0.1, ::1 and localhost only, for server authentication only; +- not a certificate authority (basicConstraints CA:FALSE), so trusting it cannot make the + machine accept a certificate for any other name; +- valid for 397 days, inside the limits Apple platforms apply to TLS server certificates. + +On macOS, `trust()` adds it to the login keychain with SSL trust only. Other platforms need no +trust step for the browsers that matter here: they already accept http://127.0.0.1. +""" + +from __future__ import annotations + +import datetime as dt +import ipaddress +import os +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID + +VALIDITY_DAYS = 397 +RENEW_BEFORE_DAYS = 30 +COMMON_NAME = "Agent Runtime loopback" +DNS_NAMES = ("localhost",) +IP_ADDRESSES = ("127.0.0.1", "::1") +LOGIN_KEYCHAIN = Path.home() / "Library" / "Keychains" / "login.keychain-db" + + +class TLSError(RuntimeError): + """A certificate or trust operation failed.""" + + +@dataclass(frozen=True) +class CertInfo: + """What `status` and `serve` need to know about the certificate on disk.""" + + cert_path: Path + key_path: Path + not_after: dt.datetime + sha1: str + sha256: str + + def expires_within(self, days: int, now: dt.datetime | None = None) -> bool: + now = now or dt.datetime.now(dt.UTC) + return self.not_after - now < dt.timedelta(days=days) + + +def _info(cert: x509.Certificate, cert_path: Path, key_path: Path) -> CertInfo: + return CertInfo( + cert_path=cert_path, + key_path=key_path, + not_after=cert.not_valid_after_utc, + sha1=cert.fingerprint(hashes.SHA1()).hex().upper(), # noqa: S324 - keychain lookup key + sha256=cert.fingerprint(hashes.SHA256()).hex().upper(), + ) + + +def load(cert_path: Path, key_path: Path) -> CertInfo | None: + """Return the certificate on disk, or None when either file is missing or unreadable.""" + if not (cert_path.is_file() and key_path.is_file()): + return None + try: + cert = x509.load_pem_x509_certificate(cert_path.read_bytes()) + except (OSError, ValueError): + return None + return _info(cert, cert_path, key_path) + + +def _write_private(path: Path, data: bytes) -> None: + """Write a file readable by the owner only, replacing any previous version atomically.""" + tmp = path.with_suffix(path.suffix + ".tmp") + fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + try: + os.write(fd, data) + finally: + os.close(fd) + os.replace(tmp, path) + + +def generate(cert_path: Path, key_path: Path, now: dt.datetime | None = None) -> CertInfo: + """Create a new key and self-signed loopback certificate, replacing any existing pair.""" + now = now or dt.datetime.now(dt.UTC) + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, COMMON_NAME)]) + alt_names: list[x509.GeneralName] = [x509.DNSName(n) for n in DNS_NAMES] + alt_names += [x509.IPAddress(ipaddress.ip_address(ip)) for ip in IP_ADDRESSES] + cert = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - dt.timedelta(minutes=5)) + .not_valid_after(now + dt.timedelta(days=VALIDITY_DAYS)) + .add_extension(x509.SubjectAlternativeName(alt_names), critical=False) + .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True) + .add_extension( + x509.KeyUsage( + digital_signature=True, + key_encipherment=True, + content_commitment=False, + data_encipherment=False, + key_agreement=False, + key_cert_sign=False, + crl_sign=False, + encipher_only=False, + decipher_only=False, + ), + critical=True, + ) + .add_extension(x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), critical=False) + .add_extension(x509.SubjectKeyIdentifier.from_public_key(key.public_key()), critical=False) + .sign(key, hashes.SHA256()) + ) + cert_path.parent.mkdir(parents=True, exist_ok=True) + os.chmod(cert_path.parent, 0o700) + _write_private( + key_path, + key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ), + ) + _write_private(cert_path, cert.public_bytes(serialization.Encoding.PEM)) + return _info(cert, cert_path, key_path) + + +def ensure(cert_path: Path, key_path: Path, force: bool = False) -> tuple[CertInfo, bool]: + """Return a usable certificate, generating one when missing, expiring or forced. + + The flag is True when a new certificate was written (and so needs trusting again). + """ + existing = None if force else load(cert_path, key_path) + if existing and not existing.expires_within(RENEW_BEFORE_DAYS): + return existing, False + return generate(cert_path, key_path), True + + +def _security(*args: str) -> subprocess.CompletedProcess[str]: + try: + return subprocess.run(["security", *args], capture_output=True, text=True, check=False) + except FileNotFoundError as error: + raise TLSError("The macOS 'security' tool was not found") from error + + +def trust_supported() -> bool: + """Whether this platform has an automated trust step (macOS only).""" + return sys.platform == "darwin" + + +def is_trusted(info: CertInfo) -> bool | None: + """Whether macOS trusts the certificate for SSL; None where this cannot be checked.""" + if not trust_supported(): + return None + try: + result = _security("verify-cert", "-c", str(info.cert_path), "-p", "ssl") + except TLSError: + return None + return result.returncode == 0 + + +def trust(info: CertInfo) -> None: + """Add the certificate to the login keychain, trusted for SSL only (asks for a password).""" + if not trust_supported(): + raise TLSError("Automatic trust is only available on macOS") + result = _security( + "add-trusted-cert", + "-r", + "trustRoot", + "-p", + "ssl", + "-k", + str(LOGIN_KEYCHAIN), + str(info.cert_path), + ) + if result.returncode != 0: + detail = (result.stderr or result.stdout).strip() + raise TLSError(f"security add-trusted-cert failed: {detail}") + + +def untrust(info: CertInfo) -> None: + """Remove the certificate's trust setting and the certificate from the login keychain.""" + if not trust_supported(): + return + _security("remove-trusted-cert", str(info.cert_path)) + _security("delete-certificate", "-Z", info.sha1, str(LOGIN_KEYCHAIN)) + + +def remove_files(info: CertInfo) -> None: + for path in (info.cert_path, info.key_path): + path.unlink(missing_ok=True) diff --git a/tests/test_config.py b/tests/test_config.py index 74c7ff6..958bc6f 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -18,6 +18,7 @@ def test_default_settings(self): assert settings.host == "127.0.0.1" assert settings.port == 9477 + assert settings.https_port == 9478 assert settings.debug is False assert settings.require_pairing is True assert settings.pairing_timeout == 300 diff --git a/tests/test_serving.py b/tests/test_serving.py new file mode 100644 index 0000000..91d138f --- /dev/null +++ b/tests/test_serving.py @@ -0,0 +1,116 @@ +"""Tests for running the HTTP and HTTPS listeners together (agent_runtime.serving, `serve`).""" + +import os +import signal +import socket +import ssl +import subprocess +import sys +import time +from pathlib import Path + +import httpx +import pytest +from fastapi import FastAPI + +from agent_runtime import tls +from agent_runtime.serving import build_listeners, request_shutdown + + +def _free_port() -> int: + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def _wait_for(port: int, timeout: float = 20) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + with socket.socket() as sock: + if sock.connect_ex(("127.0.0.1", port)) == 0: + return + time.sleep(0.1) + raise AssertionError(f"nothing listening on {port}") + + +class TestBuildListeners: + def test_http_only_without_a_certificate(self): + listeners = build_listeners(FastAPI(), "127.0.0.1", 9477, https_port=9478) + assert [listener.url for listener in listeners] == ["http://127.0.0.1:9477"] + + def test_https_shares_the_app_but_not_the_lifespan(self, tmp_path: Path): + info = tls.generate(tmp_path / "c.pem", tmp_path / "k.pem") + app = FastAPI() + listeners = build_listeners( + app, + "127.0.0.1", + 9477, + https_port=9478, + certfile=str(info.cert_path), + keyfile=str(info.key_path), + ) + http, https = (listener.server.config for listener in listeners) + assert [listener.url for listener in listeners] == [ + "http://127.0.0.1:9477", + "https://127.0.0.1:9478", + ] + assert http.app is app and https.app is app + assert https.ssl_certfile == str(info.cert_path) and https.lifespan == "off" + assert http.lifespan != "off" + + def test_second_shutdown_request_forces_exit(self): + listeners = build_listeners(FastAPI(), "127.0.0.1", 9477) + request_shutdown(listeners) + assert listeners[0].server.should_exit and not listeners[0].server.force_exit + request_shutdown(listeners) + assert listeners[0].server.force_exit + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX signals") +def test_serve_answers_on_http_and_verified_https_and_stops_on_ctrl_c(tmp_path: Path): + """The real command: both listeners up, HTTPS verifies for 127.0.0.1, one SIGINT stops both.""" + runtime_dir = tmp_path / "runtime" + tls_dir = runtime_dir / "tls" + info = tls.generate(tls_dir / "localhost.pem", tls_dir / "localhost-key.pem") + http_port, https_port = _free_port(), _free_port() + env = dict( + os.environ, + AGENT_RUNTIME_RUNTIME_DIR=str(runtime_dir), + AGENT_RUNTIME_REQUIRE_PAIRING="true", + ) + proc = subprocess.Popen( + [sys.executable, "-m", "agent_runtime.cli", "serve"] + + ["--port", str(http_port), "--https-port", str(https_port)], + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + try: + _wait_for(http_port) + _wait_for(https_port) + assert httpx.get(f"http://127.0.0.1:{http_port}/health").status_code == 200 + trusted = ssl.create_default_context(cafile=str(info.cert_path)) + for host in ("127.0.0.1", "localhost"): + response = httpx.get(f"https://{host}:{https_port}/health", verify=trusted) + assert response.status_code == 200 + info_response = httpx.get(f"https://127.0.0.1:{https_port}/runtime/info", verify=trusted) + assert info_response.json()["runtime_version"] + # Pairing is enforced on the HTTPS listener exactly as on HTTP. + denied = httpx.post( + f"https://127.0.0.1:{https_port}/kernel/start", + json={"lab_id": "x"}, + headers={"Origin": "https://example.com"}, + verify=trusted, + ) + assert denied.status_code == 401 + finally: + proc.send_signal(signal.SIGINT) + try: + output, _ = proc.communicate(timeout=15) + except subprocess.TimeoutExpired: + proc.kill() + output, _ = proc.communicate() + pytest.fail(f"serve did not stop on SIGINT:\n{output}") + assert proc.returncode == 0, output + assert f"https://127.0.0.1:{https_port}" in output diff --git a/tests/test_tls.py b/tests/test_tls.py new file mode 100644 index 0000000..f96401e --- /dev/null +++ b/tests/test_tls.py @@ -0,0 +1,175 @@ +"""Tests for the loopback TLS certificate (agent_runtime.tls).""" + +import datetime as dt +import ipaddress +import stat +import subprocess +from pathlib import Path +from unittest.mock import patch + +import pytest +from cryptography import x509 +from cryptography.hazmat.primitives import serialization +from cryptography.x509.oid import ExtendedKeyUsageOID + +from agent_runtime import tls + + +@pytest.fixture +def paths(tmp_path: Path) -> tuple[Path, Path]: + return tmp_path / "tls" / "localhost.pem", tmp_path / "tls" / "localhost-key.pem" + + +def _cert(path: Path) -> x509.Certificate: + return x509.load_pem_x509_certificate(path.read_bytes()) + + +class TestGenerate: + def test_names_only_loopback(self, paths): + info = tls.generate(*paths) + san = _cert(info.cert_path).extensions.get_extension_for_class(x509.SubjectAlternativeName) + assert san.value.get_values_for_type(x509.DNSName) == ["localhost"] + assert san.value.get_values_for_type(x509.IPAddress) == [ + ipaddress.ip_address("127.0.0.1"), + ipaddress.ip_address("::1"), + ] + + def test_cannot_act_as_a_certificate_authority(self, paths): + cert = _cert(tls.generate(*paths).cert_path) + constraints = cert.extensions.get_extension_for_class(x509.BasicConstraints) + assert constraints.critical and constraints.value.ca is False + usage = cert.extensions.get_extension_for_class(x509.KeyUsage).value + assert usage.key_cert_sign is False and usage.crl_sign is False + eku = cert.extensions.get_extension_for_class(x509.ExtendedKeyUsage).value + assert list(eku) == [ExtendedKeyUsageOID.SERVER_AUTH] + + def test_validity_within_apple_limits(self, paths): + cert = _cert(tls.generate(*paths).cert_path) + lifetime = cert.not_valid_after_utc - cert.not_valid_before_utc + assert lifetime <= dt.timedelta(days=398) + + def test_key_is_private_and_matches(self, paths): + info = tls.generate(*paths) + assert stat.S_IMODE(info.key_path.stat().st_mode) == 0o600 + assert stat.S_IMODE(info.key_path.parent.stat().st_mode) == 0o700 + key = serialization.load_pem_private_key(info.key_path.read_bytes(), password=None) + cert_key = _cert(info.cert_path).public_key() + spki = serialization.PublicFormat.SubjectPublicKeyInfo + assert key.public_key().public_bytes( + serialization.Encoding.DER, spki + ) == cert_key.public_bytes(serialization.Encoding.DER, spki) + + +class TestEnsure: + def test_reuses_a_valid_certificate(self, paths): + first, created = tls.ensure(*paths) + again, created_again = tls.ensure(*paths) + assert created and not created_again + assert again.sha256 == first.sha256 + + def test_renews_one_that_expires_soon(self, paths): + long_ago = dt.datetime.now(dt.UTC) - dt.timedelta(days=tls.VALIDITY_DAYS - 10) + old = tls.generate(*paths, now=long_ago) + assert old.expires_within(tls.RENEW_BEFORE_DAYS) + renewed, created = tls.ensure(*paths) + assert created and renewed.sha256 != old.sha256 + + def test_force_replaces(self, paths): + first, _ = tls.ensure(*paths) + forced, created = tls.ensure(*paths, force=True) + assert created and forced.sha256 != first.sha256 + + def test_load_missing_or_corrupt_is_none(self, paths): + cert_path, key_path = paths + assert tls.load(cert_path, key_path) is None + tls.generate(cert_path, key_path) + cert_path.write_text("not a certificate") + assert tls.load(cert_path, key_path) is None + + +class TestTrust: + def _ok(self, *args, **kwargs): + return subprocess.CompletedProcess(args[0], 0, "", "") + + def test_trust_adds_ssl_only_trust_to_the_login_keychain(self, paths): + info = tls.generate(*paths) + with ( + patch.object(tls.sys, "platform", "darwin"), + patch.object(tls.subprocess, "run", side_effect=self._ok) as run, + ): + tls.trust(info) + assert run.call_args.args[0] == [ + "security", + "add-trusted-cert", + "-r", + "trustRoot", + "-p", + "ssl", + "-k", + str(tls.LOGIN_KEYCHAIN), + str(info.cert_path), + ] + + def test_trust_failure_is_reported(self, paths): + info = tls.generate(*paths) + failed = subprocess.CompletedProcess([], 1, "", "User canceled the operation.") + with ( + patch.object(tls.sys, "platform", "darwin"), + patch.object(tls.subprocess, "run", return_value=failed), + pytest.raises(tls.TLSError, match="User canceled"), + ): + tls.trust(info) + + def test_untrust_removes_trust_then_the_certificate(self, paths): + info = tls.generate(*paths) + with ( + patch.object(tls.sys, "platform", "darwin"), + patch.object(tls.subprocess, "run", side_effect=self._ok) as run, + ): + tls.untrust(info) + calls = [c.args[0] for c in run.call_args_list] + assert calls == [ + ["security", "remove-trusted-cert", str(info.cert_path)], + ["security", "delete-certificate", "-Z", info.sha1, str(tls.LOGIN_KEYCHAIN)], + ] + + def test_other_platforms_have_no_trust_step(self, paths): + info = tls.generate(*paths) + with patch.object(tls.sys, "platform", "linux"): + assert tls.is_trusted(info) is None + with pytest.raises(tls.TLSError): + tls.trust(info) + tls.untrust(info) # a no-op, not an error + + +class TestCli: + def _run(self, runtime_dir: Path, *args: str) -> subprocess.CompletedProcess[str]: + import os + import sys + + env = dict(os.environ, AGENT_RUNTIME_RUNTIME_DIR=str(runtime_dir)) + return subprocess.run( + [sys.executable, "-m", "agent_runtime.cli", "tls", *args], + env=env, + capture_output=True, + text=True, + check=False, + ) + + def test_setup_status_remove(self, tmp_path: Path): + runtime_dir = tmp_path / "runtime" + assert "No certificate" in self._run(runtime_dir, "status").stdout + + setup = self._run(runtime_dir, "setup", "--no-trust") + assert setup.returncode == 0, setup.stderr + assert "Created certificate" in setup.stdout and "Not trusted" in setup.stdout + assert (runtime_dir / "tls" / "localhost.pem").is_file() + + again = self._run(runtime_dir, "setup", "--no-trust") + assert "Using certificate" in again.stdout + + status = self._run(runtime_dir, "status") + assert "expires" in status.stdout and "SHA-256" in status.stdout + + removed = self._run(runtime_dir, "remove") + assert removed.returncode == 0 and not (runtime_dir / "tls" / "localhost.pem").exists()