diff --git a/contracts/functional/messages-admission.md b/contracts/functional/messages-admission.md index 8359a22..5d06866 100644 --- a/contracts/functional/messages-admission.md +++ b/contracts/functional/messages-admission.md @@ -81,3 +81,16 @@ Protocol references inspected 2026-09-09: [streaming](https://platform.claude.com/docs/en/build-with-claude/streaming), [context editing](https://platform.claude.com/docs/en/build-with-claude/context-editing), [prompt caching](https://platform.claude.com/docs/en/build-with-claude/prompt-caching). + +## Private Unix hosting + +`MessagesServer(..., unix_path=Path(...))` binds AF_UNIX only, mode 0600 inside +an owner-owned mode 0700 directory. It refuses an existing path rather than +unlinking another listener, and removes only its own socket inode on stop. +At most 16 request handlers are active; idle header reads time out. TCP host/port +selection cannot coexist with Unix mode. The protocol and durable meter interface +are unchanged. Rein's MessagesOwner supplies the private listener, accepted lease +and cancellation hooks; sand-boxer mounts only the socket and enforces sole routing. +The provider key and ledger remain outside the workload. This source/library path +is tested with a fake provider; accepted custody, protected bootstrap/artifact and +Railiance placement are still required by LLM-WP-0009-T03. diff --git a/llm_connect/messages_gate.py b/llm_connect/messages_gate.py index 157261c..c7d8f14 100644 --- a/llm_connect/messages_gate.py +++ b/llm_connect/messages_gate.py @@ -10,11 +10,16 @@ from __future__ import annotations import hashlib import http.client import json +import os import re +import socket +import stat import threading import time from dataclasses import asdict, dataclass from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from socketserver import ThreadingUnixStreamServer from typing import Any, NoReturn, Protocol, TypeGuard, cast from urllib.parse import urlsplit @@ -345,6 +350,34 @@ class _OwnerHTTPServer(ThreadingHTTPServer): owner: MessagesServer +class _OwnerUnixServer(ThreadingUnixStreamServer): + owner: MessagesServer + daemon_threads = True + + def server_bind(self) -> None: + self._slots = threading.BoundedSemaphore(16) + super().server_bind() + + def process_request(self, request: socket.socket | tuple[bytes, socket.socket], client_address: Any) -> None: + if not isinstance(request, socket.socket): + raise TypeError("Unix stream socket required") + if not self._slots.acquire(blocking=False): + request.close() + return + request.settimeout(10) + try: + super().process_request(request, client_address) + except BaseException: + self._slots.release() + raise + + def process_request_thread(self, request: socket.socket | tuple[bytes, socket.socket], client_address: Any) -> None: + try: + super().process_request_thread(request, client_address) + finally: + self._slots.release() + + class _Handler(BaseHTTPRequestHandler): def log_message(self, format: str, *args: Any) -> None: pass @@ -360,7 +393,7 @@ class _Handler(BaseHTTPRequestHandler): self.wfile.write(raw) def do_POST(self) -> None: - owner = cast(_OwnerHTTPServer, self.server).owner + owner = cast(_OwnerHTTPServer | _OwnerUnixServer, self.server).owner upstream = None sent = False try: @@ -496,6 +529,7 @@ class MessagesServer: host: str = "127.0.0.1", port: int = 0, allow_test_http: bool = False, + unix_path: Path | None = None, ) -> None: endpoint = urlsplit(upstream_url) if endpoint.scheme != "https" and not ( @@ -521,13 +555,36 @@ class MessagesServer: raise RequestRefused("explicit provider credential required") self.policy, self.meter, self.endpoint = policy, meter, endpoint self._provider_key = provider_key - self._httpd = _OwnerHTTPServer((host, port), _Handler) + self._unix_path = unix_path + self._httpd: _OwnerHTTPServer | _OwnerUnixServer + if unix_path is not None: + if host != "127.0.0.1" or port != 0: + raise RequestRefused("Unix route cannot also select a TCP listener") + parent = unix_path.parent + metadata = parent.lstat() + if ( + not unix_path.is_absolute() + or parent.resolve() != parent + or not stat.S_ISDIR(metadata.st_mode) + or metadata.st_uid != os.getuid() + or stat.S_IMODE(metadata.st_mode) != 0o700 + ): + raise RequestRefused("Unix route requires a private owner directory") + # bind refuses existing paths, including dangling symlinks. Never unlink + # someone else's socket to recover a crashed or duplicated owner. + self._httpd = _OwnerUnixServer(str(unix_path), _Handler) + unix_path.chmod(0o600) + self._unix_inode = unix_path.stat().st_ino + else: + self._httpd = _OwnerHTTPServer((host, port), _Handler) self._httpd.owner = self self._thread: threading.Thread | None = None @property def port(self) -> int: - return int(self._httpd.server_address[1]) + if self._unix_path is not None: + raise RequestRefused("Unix route has no TCP port") + return int(cast(_OwnerHTTPServer, self._httpd).server_address[1]) def start(self) -> None: self._thread = threading.Thread(target=self._httpd.serve_forever, daemon=True) @@ -538,3 +595,9 @@ class MessagesServer: self._httpd.shutdown() self._thread.join() self._httpd.server_close() + if self._unix_path is not None: + try: + if self._unix_path.lstat().st_ino == self._unix_inode: + self._unix_path.unlink() + except FileNotFoundError: + pass diff --git a/tests/test_messages_gate.py b/tests/test_messages_gate.py index 66fa167..f74c297 100644 --- a/tests/test_messages_gate.py +++ b/tests/test_messages_gate.py @@ -187,3 +187,37 @@ def test_fixed_origin_no_redirect_proxy_or_request_url(policy, url): MessagesServer( policy, DeniedMeter(), provider_key="dummy", upstream_url=url, allow_test_http=True ) + + +def test_unix_listener_private_directory_and_no_tcp(tmp_path, policy): + import http.client + import socket + + from llm_connect.messages_gate import MessagesServer, RequestRefused + + private = tmp_path / "private" + private.mkdir(mode=0o700) + path = private / "messages.sock" + server = MessagesServer(policy, DeniedMeter(), provider_key="dummy", unix_path=path) + assert path.stat().st_mode & 0o777 == 0o600 + with pytest.raises(RequestRefused, match="no TCP"): + _ = server.port + with pytest.raises(OSError): + MessagesServer(policy, DeniedMeter(), provider_key="dummy", unix_path=path) + server.start() + try: + connection = http.client.HTTPConnection("localhost", timeout=3) + connection.sock = socket.socket(socket.AF_UNIX) + connection.sock.settimeout(3) + connection.sock.connect(str(path)) + connection.request("POST", "/execute", "{}", {"Content-Type": "application/json"}) + response = connection.getresponse() + assert response.status == 404 + response.read() + connection.close() + finally: + server.stop() + assert not path.exists() + private.chmod(0o755) + with pytest.raises(RequestRefused, match="private"): + MessagesServer(policy, DeniedMeter(), provider_key="dummy", unix_path=path) diff --git a/workplans/LLM-WP-0009-owner-metered-messages-transport.md b/workplans/LLM-WP-0009-owner-metered-messages-transport.md index 3cf8b15..6292f35 100644 --- a/workplans/LLM-WP-0009-owner-metered-messages-transport.md +++ b/workplans/LLM-WP-0009-owner-metered-messages-transport.md @@ -57,7 +57,7 @@ No actual inference, provider credential or live price/FX policy is involved. id: LLM-WP-0009-T03 status: wait priority: high -blocking_reason: "Requires trusted owner hosting and actual lease/token delivery, provider custody and direct-route denial under HFACT T03/T04; accepted tariff/FX and G0 remain HFACT T01." +blocking_reason: "Local Unix hosting, worker lease/token lifecycle and bwrap confinement proved; requires admitted credential-to-owner bootstrap, matched protected artifact and Railiance custody/placement under HFACT T03/T04; live tariff/FX and G0 remain HFACT T01." state_hub_task_id: "98a38d75-73ab-5f37-b710-5df9d9681e49" ``` @@ -81,6 +81,35 @@ Resolve or explicitly disposition those existing checks before an owner accepts the protected artifact/release. Evidence: `docs/evidence/2026-09-09-request-admission-quality.json`. +### Local owner-route integration return — 2026-09-09 + +`MessagesOwner` now starts an owner-only Unix Messages listener after the worker +reserves its parent envelope. The initial accepted Activity Core heartbeat supplies +its exact expiry, run, worker and attempt binding. Lease loss, timeout, signals, +gateway exceptions and normal exit revoke the route. A timer also enforces the +initial lease deadline; heartbeat renewal does not extend this first route. +Only the opaque token and namespace-local base URL reach the child. Provider key, +ledger and owner socket directory remain outside the workload's mounts and PID +namespace. The sandbox refuses direct egress, alternate credential delivery, +extra host mounts, consumer mismatches and additional sandboxes for this binding. + +Real local bwrap tests prove sole-route forwarding, direct host/public-IP denial, +private-state absence, revocation with no second forward, and teardown. The actual +worker/Glas/bwrap/ledger path also imports its permitted fixture commit and replays +a lost terminal close without repeating the request or authoring. Queue, provider, +credential and authoring remain deterministic fixtures; factory attempts remain 0. +Worker suite: 377 passed, including installed CLI and real namespace tests. +Sand-boxer required `make check`: lint clean, 199 tests passed. LLM suite: 264 +passed; changed transport adds no lint/type diagnostics, with existing full-repo +177 Ruff/36 mypy diagnostics still requiring pre-release disposition. + +This closes local source route/lease/token/confinement wiring. Remaining return: +admitted owner bootstrap that supplies the provider key to `MessagesOwner`, matched +protected runtime/CLI artifact, Railiance host/profile/consumer/custody/recovery +admission, live provider compatibility and accepted bounds/tariffs/FX, then G0 and +natural model/queue evidence. No protected runtime was installed or promoted, +no existing CCR changed, no secret read or paid execution took place. + ## Repair historical source identities blocking primary synchronization ```task