From 12dedce2c0aa60c2b2546f991020dfba1427db2a Mon Sep 17 00:00:00 2001 From: Kyle Widmann Date: Tue, 2 Dec 2025 08:00:37 -0500 Subject: [PATCH 01/17] Move dummy ports into 2 phase verification --- chutes/entrypoint/run.py | 122 ++-------------- chutes/entrypoint/verify.py | 275 +++++++++++++++++++++++++++--------- 2 files changed, 225 insertions(+), 172 deletions(-) diff --git a/chutes/entrypoint/run.py b/chutes/entrypoint/run.py index 875ba92..b0e89b5 100644 --- a/chutes/entrypoint/run.py +++ b/chutes/entrypoint/run.py @@ -17,7 +17,6 @@ import typer import psutil import base64 -import socket import secrets import threading import traceback @@ -30,7 +29,7 @@ from pydantic import BaseModel from ipaddress import ip_address from uvicorn import Config, Server -from fastapi import Request, Response, status, HTTPException +from fastapi import FastAPI, Request, Response, status, HTTPException from fastapi.responses import ORJSONResponse from starlette.middleware.base import BaseHTTPMiddleware from chutes.entrypoint.verify import GpuVerifier @@ -641,7 +640,7 @@ async def dispatch(self, request: Request, call_next): class GraValMiddleware(BaseHTTPMiddleware): - def __init__(self, app, concurrency: int = 1): + def __init__(self, app: FastAPI, concurrency: int = 1): """ Initialize a semaphore for concurrency control/limits. """ @@ -822,105 +821,12 @@ async def wrapped_iterator(): if not response or not hasattr(response, "body_iterator"): _conn_stats.requests_in_flight.pop(request.request_id, None) - -def start_dummy_socket(port_mapping, symmetric_key): - """ - Start a dummy socket based on the port mapping configuration to validate ports. - """ - proto = port_mapping["proto"].lower() - internal_port = port_mapping["internal_port"] - response_text = f"response from {proto} {internal_port}" - if proto in ["tcp", "http"]: - return start_tcp_dummy(internal_port, symmetric_key, response_text) - return start_udp_dummy(internal_port, symmetric_key, response_text) - - -def encrypt_response(symmetric_key, plaintext): - """ - Encrypt the response using AES-CBC with PKCS7 padding. - """ - padder = padding.PKCS7(128).padder() - new_iv = secrets.token_bytes(16) - cipher = Cipher( - algorithms.AES(symmetric_key), - modes.CBC(new_iv), - backend=default_backend(), - ) - padded_data = padder.update(plaintext.encode()) + padder.finalize() - encryptor = cipher.encryptor() - encrypted_data = encryptor.update(padded_data) + encryptor.finalize() - response_cipher = base64.b64encode(encrypted_data).decode() - return new_iv, response_cipher - - -def start_tcp_dummy(port, symmetric_key, response_plaintext): - """ - TCP port check socket. - """ - - def tcp_handler(): - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - try: - sock.bind(("0.0.0.0", port)) - sock.listen(1) - logger.info(f"TCP socket listening on port {port}") - conn, addr = sock.accept() - logger.info(f"TCP connection from {addr}") - data = conn.recv(1024) - logger.info(f"TCP received: {data.decode('utf-8', errors='ignore')}") - iv, encrypted_response = encrypt_response(symmetric_key, response_plaintext) - full_response = f"{iv.hex()}|{encrypted_response}".encode() - conn.send(full_response) - logger.info(f"TCP sent encrypted response on port {port}: {full_response=}") - conn.close() - except Exception as e: - logger.info(f"TCP socket error on port {port}: {e}") - raise - finally: - sock.close() - logger.info(f"TCP socket on port {port} closed") - - thread = threading.Thread(target=tcp_handler, daemon=True) - thread.start() - return thread - - -def start_udp_dummy(port, symmetric_key, response_plaintext): - """ - UDP port check socket. - """ - - def udp_handler(): - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - try: - sock.bind(("0.0.0.0", port)) - logger.info(f"UDP socket listening on port {port}") - data, addr = sock.recvfrom(1024) - logger.info(f"UDP received from {addr}: {data.decode('utf-8', errors='ignore')}") - iv, encrypted_response = encrypt_response(symmetric_key, response_plaintext) - full_response = f"{iv.hex()}|{encrypted_response}".encode() - sock.sendto(full_response, addr) - logger.info(f"UDP sent encrypted response on port {port}") - except Exception as e: - logger.info(f"UDP socket error on port {port}: {e}") - raise - finally: - sock.close() - logger.info(f"UDP socket on port {port} closed") - - thread = threading.Thread(target=udp_handler, daemon=True) - thread.start() - return thread - - async def _gather_devices_and_initialize( host: str, port_mappings: list[dict[str, Any]], chute_abspath: str, inspecto_hash: str, -) -> tuple[bool, str, dict[str, Any]]: +) -> tuple[bool, bytes, dict[str, Any]]: """ Gather the GPU info assigned to this pod, submit with our one-time token to get GraVal seed. """ @@ -973,16 +879,18 @@ async def _gather_devices_and_initialize( logger.error(f"Error checking disk space: {e}") raise Exception(f"Failed to verify disk space availability: {e}") - # Start up dummy sockets to test port mappings. - dummy_socket_threads = [] - for port_map in port_mappings: - if port_map.get("default"): - continue - dummy_socket_threads.append(start_dummy_socket(port_map, symmetric_key)) - - # Verify GPUs for symmetric key + # Verify GPUs, spin up dummy sockets, and finalize verification. verifier = GpuVerifier.create(url, body) - symmetric_key, response = await verifier.verify_devices() + symmetric_key, response = await verifier.verify() + + # Derive runint session key from validator's pubkey via ECDH if provided + # Key derivation happens entirely in C - key never touches Python memory + validator_pubkey = response.get("validator_pubkey") + if validator_pubkey: + if runint_derive_session_key(validator_pubkey): + logger.success("Derived runint session key via ECDH (key never in Python)") + else: + logger.warning("Failed to derive runint session key - using legacy encryption") # Derive runint session key from validator's pubkey via ECDH if provided # Key derivation happens entirely in C - key never touches Python memory @@ -1228,7 +1136,7 @@ async def _run_chute(): # GPU verification plus job fetching. job_data: dict | None = None - symmetric_key: str | None = None + symmetric_key: bytes | None = None job_id: str | None = None job_obj: Job | None = None job_method: str | None = None diff --git a/chutes/entrypoint/verify.py b/chutes/entrypoint/verify.py index 8f2723b..9dab402 100644 --- a/chutes/entrypoint/verify.py +++ b/chutes/entrypoint/verify.py @@ -4,6 +4,8 @@ import json import os import ssl +import socket +import threading from urllib.parse import urljoin, urlparse import aiohttp @@ -17,6 +19,8 @@ def __init__(self, url, body): self._token = get_launch_token() self._url = url self._body = body + self._symmetric_key: bytes | None = None + self._dummy_threads: list[threading.Thread] = [] @classmethod def create(cls, url, body) -> "GpuVerifier": @@ -25,12 +29,38 @@ def create(cls, url, body) -> "GpuVerifier": else: return GravalGpuVerifier(url, body) + def _start_dummy_sockets(self): + if not self._symmetric_key: + raise RuntimeError("Cannot start dummy sockets without symmetric key.") + for port_map in self._body.get("port_mappings", []): + if port_map.get("default"): + continue + self._dummy_threads.append(start_dummy_socket(port_map, self._symmetric_key)) + + async def verify(self): + """ + Execute full verification flow and spin up dummy sockets for port validation. + """ + symmetric_key = await self.fetch_symmetric_key() + self._start_dummy_sockets() + response = await self.finalize_verification() + return symmetric_key, response + + @abstractmethod + async def fetch_symmetric_key(self) -> bytes: ... + @abstractmethod - async def verify_devices(self): ... + async def finalize_verification(self) -> dict: ... class GravalGpuVerifier(GpuVerifier): - async def verify_devices(self): + def __init__(self, url, body): + super().__init__(url, body) + self._init_params: dict | None = None + self._proofs = None + self._response_plaintext: str | None = None + + async def fetch_symmetric_key(self): # Fetch the challenges. token = self._token url = self._url @@ -40,65 +70,76 @@ async def verify_devices(self): async with aiohttp.ClientSession(raise_for_status=True) as session: logger.info(f"Collected all environment data, submitting to validator: {url}") async with session.post(url, headers={"Authorization": token}, json=body) as resp: - init_params = await resp.json() - logger.success(f"Successfully fetched initialization params: {init_params=}") - - # First, we initialize graval on all GPUs from the provided seed. - miner()._graval_seed = init_params["seed"] - iterations = init_params.get("iterations", 1) - logger.info(f"Generating proofs from seed={miner()._graval_seed}") - proofs = miner().prove(miner()._graval_seed, iterations=iterations) - - # Use GraVal to extract the symmetric key from the challenge. - sym_key = init_params["symmetric_key"] - bytes_ = base64.b64decode(sym_key["ciphertext"]) - iv = bytes_[:16] - cipher = bytes_[16:] - logger.info("Decrypting payload via proof challenge matrix...") - device_index = [ - miner().get_device_info(i)["uuid"] for i in range(miner()._device_count) - ].index(sym_key["uuid"]) - symmetric_key = bytes.fromhex( - miner().decrypt( - init_params["seed"], - cipher, - iv, - len(cipher), - device_index, - ) - ) - - # Now, we can respond to the URL by encrypting a payload with the symmetric key and sending it back. - plaintext = sym_key["response_plaintext"] - new_iv, response_cipher = encrypt_response(symmetric_key, plaintext) + self._init_params = await resp.json() logger.success( - f"Completed PoVW challenge, sending back: {plaintext=} " - f"as {response_cipher=} where iv={new_iv.hex()}" + f"Successfully fetched initialization params: {self._init_params=}" ) - # Post the response to the challenge, which returns job data (if any). - async with session.put( - url, - headers={"Authorization": token}, - json={ - "response": response_cipher, - "iv": new_iv.hex(), - "proof": proofs, - }, - raise_for_status=False, - ) as resp: - if resp.ok: - logger.success("Successfully negotiated challenge response!") - response = await resp.json() - # validator_pubkey is returned in POST response, needed for ECDH session key - if "validator_pubkey" in init_params: - response["validator_pubkey"] = init_params["validator_pubkey"] - return symmetric_key, response - else: - # log down the reason of failure to the challenge - detail = await resp.text(encoding="utf-8", errors="replace") - logger.error(f"Failed: {resp.reason} ({resp.status}) {detail}") - resp.raise_for_status() + # First, we initialize graval on all GPUs from the provided seed. + miner()._graval_seed = self._init_params["seed"] + iterations = self._init_params.get("iterations", 1) + logger.info(f"Generating proofs from seed={miner()._graval_seed}") + self._proofs = miner().prove(miner()._graval_seed, iterations=iterations) + + # Use GraVal to extract the symmetric key from the challenge. + sym_key = self._init_params["symmetric_key"] + bytes_ = base64.b64decode(sym_key["ciphertext"]) + iv = bytes_[:16] + cipher = bytes_[16:] + logger.info("Decrypting payload via proof challenge matrix...") + device_index = [ + miner().get_device_info(i)["uuid"] for i in range(miner()._device_count) + ].index(sym_key["uuid"]) + self._symmetric_key = bytes.fromhex( + miner().decrypt( + self._init_params["seed"], + cipher, + iv, + len(cipher), + device_index, + ) + ) + + # Now, we can respond to the URL by encrypting a payload with the symmetric key and sending it back. + self._response_plaintext = sym_key["response_plaintext"] + return self._symmetric_key + + async def finalize_verification(self): + + token = self._token + url = self._url + plaintext = self._response_plaintext + + async with aiohttp.ClientSession(raise_for_status=True) as session: + new_iv, response_cipher = encrypt_response(self._symmetric_key, plaintext) + logger.success( + f"Completed PoVW challenge, sending back: {plaintext=} " + f"as {response_cipher=} where iv={new_iv.hex()}" + ) + + # Post the response to the challenge, which returns job data (if any). + async with session.put( + url, + headers={"Authorization": token}, + json={ + "response": response_cipher, + "iv": new_iv.hex(), + "proof": self._proofs, + }, + raise_for_status=False, + ) as resp: + if resp.ok: + logger.success("Successfully negotiated challenge response!") + response = await resp.json() + # validator_pubkey is returned in POST response, needed for ECDH session key + if "validator_pubkey" in self._init_params: + response["validator_pubkey"] = self._init_params["validator_pubkey"] + return response + else: + # log down the reason of failure to the challenge + detail = await resp.text(encoding="utf-8", errors="replace") + logger.error(f"Failed: {resp.reason} ({resp.status}) {detail}") + resp.raise_for_status() def gather_gpus(self): gpus = [] @@ -109,6 +150,7 @@ def gather_gpus(self): class TeeGpuVerifier(GpuVerifier): + @asynccontextmanager async def _attestation_session(self): """ @@ -161,22 +203,51 @@ async def _get_gpu_evidence(self): evidence = json.loads(await resp.json()) return nonce, evidence - async def verify_devices(self): + async def fetch_symmetric_key(self): token = self._token url = urljoin(f"{self._url}/", "attest") body = self._body body["gpus"] = await self.gather_gpus() - nonce, evidence = await self._get_gpu_evidence() + _nonce, evidence = await self._get_gpu_evidence() body["gpu_evidence"] = evidence async with aiohttp.ClientSession(raise_for_status=True) as session: - headers = {"Authorization": token, "X-Chutes-Nonce": nonce} - logger.info(f"Collected all environment data, submitting to validator: {url}") + headers = {"Authorization": token, "X-Chutes-Nonce": _nonce} + logger.info(f"Collected all environment data, submitting to validator for symmetric key: {url}") async with session.post(url, headers=headers, json=body) as resp: - logger.info("Successfully verified instance with validator.") + logger.info("Successfully fetched symmetric key and attestation response.") data = await resp.json() - symmetric_key = bytes.fromhex(data["symmetric_key"]) - return symmetric_key, data + self._symmetric_key = bytes.fromhex(data["symmetric_key"]) + return self._symmetric_key + + async def finalize_verification(self): + if not self._symmetric_key: + raise RuntimeError("Symmetric key must be fetched before finalizing verification.") + + token = self._token + url = urljoin(f"{self._url}/", "attest") + headers = {"Authorization": token} + + async with aiohttp.ClientSession(raise_for_status=False) as session: + logger.info("Requesting validator to verify ports with initialized symmetric key.") + async with session.put( + url, + headers=headers, + json={"port_mappings": self._body.get("port_mappings", [])}, + ) as resp: + if resp.ok: + if resp.content_type == "application/json": + return await resp.json() + logger.success("Ports verified successfully.") + return {} + if resp.status in (404, 405): + logger.warning( + f"Port verification endpoint not available ({resp.status}); validator must include final response." + ) + return {} + detail = await resp.text(encoding="utf-8", errors="replace") + logger.error(f"Port verification failed: {resp.reason} ({resp.status}) {detail}") + resp.raise_for_status() async def gather_gpus(self): devices = [] @@ -188,3 +259,77 @@ async def gather_gpus(self): logger.success(f"Retrieved {len(devices)} GPUs.") return devices + + +def start_dummy_socket(port_mapping, symmetric_key): + """ + Start a dummy socket based on the port mapping configuration to validate ports. + """ + proto = port_mapping["proto"].lower() + internal_port = port_mapping["internal_port"] + response_text = f"response from {proto} {internal_port}" + if proto in ["tcp", "http"]: + return start_tcp_dummy(internal_port, symmetric_key, response_text) + return start_udp_dummy(internal_port, symmetric_key, response_text) + + +def start_tcp_dummy(port, symmetric_key, response_plaintext): + """ + TCP port check socket. + """ + + def tcp_handler(): + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + sock.bind(("0.0.0.0", port)) + sock.listen(1) + logger.info(f"TCP socket listening on port {port}") + conn, addr = sock.accept() + logger.info(f"TCP connection from {addr}") + data = conn.recv(1024) + logger.info(f"TCP received: {data.decode('utf-8', errors='ignore')}") + iv, encrypted_response = encrypt_response(symmetric_key, response_plaintext) + full_response = f"{iv.hex()}|{encrypted_response}".encode() + conn.send(full_response) + logger.info(f"TCP sent encrypted response on port {port}: {full_response=}") + conn.close() + except Exception as e: + logger.info(f"TCP socket error on port {port}: {e}") + raise + finally: + sock.close() + logger.info(f"TCP socket on port {port} closed") + + thread = threading.Thread(target=tcp_handler, daemon=True) + thread.start() + return thread + + +def start_udp_dummy(port, symmetric_key, response_plaintext): + """ + UDP port check socket. + """ + + def udp_handler(): + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + sock.bind(("0.0.0.0", port)) + logger.info(f"UDP socket listening on port {port}") + data, addr = sock.recvfrom(1024) + logger.info(f"UDP received from {addr}: {data.decode('utf-8', errors='ignore')}") + iv, encrypted_response = encrypt_response(symmetric_key, response_plaintext) + full_response = f"{iv.hex()}|{encrypted_response}".encode() + sock.sendto(full_response, addr) + logger.info(f"UDP sent encrypted response on port {port}") + except Exception as e: + logger.info(f"UDP socket error on port {port}: {e}") + raise + finally: + sock.close() + logger.info(f"UDP socket on port {port} closed") + + thread = threading.Thread(target=udp_handler, daemon=True) + thread.start() + return thread From d9e3b641f6630fcfc23182b59d5dac2a6b181925 Mon Sep 17 00:00:00 2001 From: Kyle Widmann Date: Fri, 19 Dec 2025 19:42:58 -0500 Subject: [PATCH 02/17] Update tee verification for new flow with port fixes --- chutes/entrypoint/run.py | 45 ++++++- chutes/entrypoint/verify.py | 246 ++++++++++++++++++++++++++---------- 2 files changed, 219 insertions(+), 72 deletions(-) diff --git a/chutes/entrypoint/run.py b/chutes/entrypoint/run.py index b0e89b5..04f5797 100644 --- a/chutes/entrypoint/run.py +++ b/chutes/entrypoint/run.py @@ -32,13 +32,18 @@ from fastapi import FastAPI, Request, Response, status, HTTPException from fastapi.responses import ORJSONResponse from starlette.middleware.base import BaseHTTPMiddleware -from chutes.entrypoint.verify import GpuVerifier +from chutes.entrypoint.verify import ( + GpuVerifier, + TEE_EVIDENCE_ENDPOINT, + tee_evidence_endpoint, +) from chutes.util.hf import verify_cache, CacheVerificationError from prometheus_client import generate_latest, CONTENT_TYPE_LATEST from substrateinterface import Keypair, KeypairType from chutes.entrypoint._shared import ( get_launch_token, get_launch_token_data, + is_tee_env, load_chute, miner, authenticate_request, @@ -835,7 +840,6 @@ async def _gather_devices_and_initialize( logger.info("Collecting GPUs and port mappings...") body = {"gpus": [], "port_mappings": port_mappings, "host": host} token_data = get_launch_token_data() - url = token_data.get("url") key = token_data.get("env_key", "a" * 32) logger.info("Collecting full envdump...") @@ -880,7 +884,7 @@ async def _gather_devices_and_initialize( raise Exception(f"Failed to verify disk space availability: {e}") # Verify GPUs, spin up dummy sockets, and finalize verification. - verifier = GpuVerifier.create(url, body) + verifier = GpuVerifier.create(body) symmetric_key, response = await verifier.verify() # Derive runint session key from validator's pubkey via ECDH if provided @@ -1368,6 +1372,41 @@ async def _handle_hf_check(request: Request): chute.add_api_route("/_hf_check", _handle_hf_check, methods=["POST"]) + async def _handle_hf_check(request: Request): + """ + Verify HuggingFace cache integrity. + """ + data = request.state.decrypted + repo_id = data.get("repo_id") + revision = data.get("revision") + full_hash_check = data.get("full_hash_check", False) + + if not repo_id or not revision: + return { + "error": True, + "reason": "bad_request", + "message": "repo_id and revision are required", + "repo_id": repo_id, + "revision": revision, + } + + try: + result = await verify_cache( + repo_id=repo_id, + revision=revision, + full_hash_check=full_hash_check, + ) + result["error"] = False + return result + except CacheVerificationError as e: + return e.to_dict() + + chute.add_api_route("/_hf_check", _handle_hf_check, methods=["POST"]) + + if is_tee_env(): + chute.add_api_route(TEE_EVIDENCE_ENDPOINT, tee_evidence_endpoint, methods=["GET"]) + + logger.success("Added all chutes internal endpoints.") # Job shutdown/kill endpoint. diff --git a/chutes/entrypoint/verify.py b/chutes/entrypoint/verify.py index 9dab402..40e9cf5 100644 --- a/chutes/entrypoint/verify.py +++ b/chutes/entrypoint/verify.py @@ -1,8 +1,10 @@ from abc import abstractmethod import base64 -from contextlib import asynccontextmanager +from contextlib import asynccontextmanager, contextmanager +from functools import lru_cache import json import os +from re import A import ssl import socket import threading @@ -10,24 +12,77 @@ import aiohttp from loguru import logger +from fastapi import Request, HTTPException, status -from chutes.entrypoint._shared import encrypt_response, get_launch_token, is_tee_env, miner +from chutes.entrypoint._shared import encrypt_response, get_launch_token, get_launch_token_data, is_tee_env, miner + + +# TEE endpoint constants +TEE_EVIDENCE_ENDPOINT = "/_tee_evidence" + +# Global nonce storage for TEE verification +# This should only be set once during the verification process +_evidence_nonce: str | None = None +_evidence_nonce_locked: bool = False + + +def _set_evidence_nonce(nonce: str) -> None: + """ + Set the TEE nonce. Can only be called once per verification process. + + Raises: + RuntimeError: If nonce is already locked (multiple verification processes detected) + """ + global _evidence_nonce, _evidence_nonce_locked + + if _evidence_nonce_locked: + raise RuntimeError( + "TEE nonce already set. Only one verification process should be running." + ) + + _evidence_nonce = nonce + _evidence_nonce_locked = True + + +@contextmanager +def _use_evidence_nonce(): + """ + Context manager for using the TEE nonce. Ensures nonce is available for the duration + and automatically clears it when done. + + Yields: + The current nonce value + + Raises: + RuntimeError: If no nonce has been set + """ + global _evidence_nonce, _evidence_nonce_locked + + if _evidence_nonce is None: + raise RuntimeError("No nonce has been set") + + try: + yield _evidence_nonce + finally: + _evidence_nonce = None + _evidence_nonce_locked = False class GpuVerifier: - def __init__(self, url, body): + def __init__(self, body: dict): self._token = get_launch_token() - self._url = url - self._body = body + token_data = get_launch_token_data() + self._url = token_data.get("url") self._symmetric_key: bytes | None = None self._dummy_threads: list[threading.Thread] = [] + self._body = body @classmethod - def create(cls, url, body) -> "GpuVerifier": + def create(cls, body: dict) -> "GpuVerifier": if is_tee_env(): - return TeeGpuVerifier(url, body) + return TeeGpuVerifier(body) else: - return GravalGpuVerifier(url, body) + return GravalGpuVerifier(body) def _start_dummy_sockets(self): if not self._symmetric_key: @@ -54,8 +109,8 @@ async def finalize_verification(self) -> dict: ... class GravalGpuVerifier(GpuVerifier): - def __init__(self, url, body): - super().__init__(url, body) + def __init__(self, body: dict): + super().__init__(body) self._init_params: dict | None = None self._proofs = None self._response_plaintext: str | None = None @@ -64,12 +119,11 @@ async def fetch_symmetric_key(self): # Fetch the challenges. token = self._token url = self._url - body = self._body - body["gpus"] = self.gather_gpus() + self._body["gpus"] = self.gather_gpus() async with aiohttp.ClientSession(raise_for_status=True) as session: logger.info(f"Collected all environment data, submitting to validator: {url}") - async with session.post(url, headers={"Authorization": token}, json=body) as resp: + async with session.post(url, headers={"Authorization": token}, json=self._body) as resp: self._init_params = await resp.json() logger.success( f"Successfully fetched initialization params: {self._init_params=}" @@ -168,86 +222,96 @@ async def _attestation_session(self): async with aiohttp.ClientSession(connector=connector, raise_for_status=True) as session: yield session - async def _get_nonce(self): + @property + @lru_cache(maxsize=1) + def validator_url(self) -> str: parsed = urlparse(self._url) + return f"{parsed.scheme}://{parsed.netloc}" + + @property + @lru_cache(maxsize=1) + def deployment_id(self) -> str: + hostname = os.environ.get("HOSTNAME") + _deployment_id = hostname.lstrip("chute-") + return _deployment_id - # Get just the scheme + netloc (host) - validator_url = f"{parsed.scheme}://{parsed.netloc}" - url = urljoin(validator_url, "/servers/nonce") + async def _get_nonce(self): + url = urljoin(self.validator_url, "/servers/nonce") async with aiohttp.ClientSession(raise_for_status=True) as http_session: async with http_session.get(url) as resp: logger.success("Successfully retrieved nonce for attestation evidence.") data = await resp.json() return data["nonce"] - async def _get_gpu_evidence(self): - """ """ - ssl_context = ssl.create_default_context() - ssl_context.check_hostname = False - ssl_context.verify_mode = ssl.CERT_NONE - - connector = aiohttp.TCPConnector(ssl=ssl_context) + async def _fetch_evidence_nonce(self): + """ + Initiate the attestation process with the validator. + """ + url = urljoin(self.validator_url, "/instances/attest") - url = "https://attestation-service-internal.attestation-system.svc.cluster.local.:8443/server/nvtrust/evidence" - nonce = await self._get_nonce() - params = { - "name": os.environ.get("HOSTNAME"), - "nonce": nonce, - "gpu_ids": os.environ.get("CHUTES_NVIDIA_DEVICES"), - } - async with aiohttp.ClientSession( - connector=connector, raise_for_status=True - ) as http_session: - async with http_session.get(url, params=params) as resp: - logger.success("Successfully retrieved attestation evidence.") - evidence = json.loads(await resp.json()) - return nonce, evidence + async with aiohttp.ClientSession(raise_for_status=True) as http_session: + async with http_session.get(url) as resp: + logger.success(f"Successfully initiated attestation with validator {self.validator_url}.") + _nonce = await resp.json() + _set_evidence_nonce(_nonce) async def fetch_symmetric_key(self): + """ + New TEE verification flow (3 phases): + Phase 1: Get nonce from validator + Phase 2: Validator calls our /_tee_evidence endpoint, we fetch evidence from attestation service, + validator verifies and returns symmetric key + Phase 3: Start dummy sockets and finalize verification (handled by base class) + """ + # First get a nonce from the validator to use with attestation evidence + await self._fetch_evidence_nonce() + + # Claim the launch config - validator will call our /_tee_evidence endpoint + # and return symmetric key after successful attestation token = self._token - url = urljoin(f"{self._url}/", "attest") - body = self._body - - body["gpus"] = await self.gather_gpus() - _nonce, evidence = await self._get_gpu_evidence() - body["gpu_evidence"] = evidence - async with aiohttp.ClientSession(raise_for_status=True) as session: - headers = {"Authorization": token, "X-Chutes-Nonce": _nonce} - logger.info(f"Collected all environment data, submitting to validator for symmetric key: {url}") - async with session.post(url, headers=headers, json=body) as resp: - logger.info("Successfully fetched symmetric key and attestation response.") - data = await resp.json() - self._symmetric_key = bytes.fromhex(data["symmetric_key"]) - return self._symmetric_key + + # Use context manager to ensure nonce is available for evidence endpoint and cleaned up after + with _use_evidence_nonce() as nonce: + async with aiohttp.ClientSession(raise_for_status=True) as session: + headers = { + "Authorization": token, + "X-Chutes-Nonce": nonce, + } + _body = self._body.copy() + _body["deployment_id"] = self.deployment_id + logger.info(f"Requesting verification from validator: {self._url}") + async with session.post(self._url, headers=headers, json=self._body) as resp: + data = await resp.json() + self._symmetric_key = bytes.fromhex(data["symmetric_key"]) + logger.success("Successfully received symmetric key from validator") + return self._symmetric_key async def finalize_verification(self): + """ + Send final verification with port mappings (same as GraVal flow). + """ if not self._symmetric_key: raise RuntimeError("Symmetric key must be fetched before finalizing verification.") token = self._token - url = urljoin(f"{self._url}/", "attest") - headers = {"Authorization": token} + url = self._url async with aiohttp.ClientSession(raise_for_status=False) as session: - logger.info("Requesting validator to verify ports with initialized symmetric key.") + logger.info("Sending final verification request.") + async with session.put( url, - headers=headers, - json={"port_mappings": self._body.get("port_mappings", [])}, + headers={"Authorization": token}, + json={}, + raise_for_status=False, ) as resp: if resp.ok: - if resp.content_type == "application/json": - return await resp.json() - logger.success("Ports verified successfully.") - return {} - if resp.status in (404, 405): - logger.warning( - f"Port verification endpoint not available ({resp.status}); validator must include final response." - ) - return {} - detail = await resp.text(encoding="utf-8", errors="replace") - logger.error(f"Port verification failed: {resp.reason} ({resp.status}) {detail}") - resp.raise_for_status() + logger.success("Successfully completed final verification!") + return await resp.json() + else: + detail = await resp.text(encoding="utf-8", errors="replace") + logger.error(f"Final verification failed: {resp.reason} ({resp.status}) {detail}") + resp.raise_for_status() async def gather_gpus(self): devices = [] @@ -333,3 +397,47 @@ def udp_handler(): thread = threading.Thread(target=udp_handler, daemon=True) thread.start() return thread + + +async def tee_evidence_endpoint(request: Request): + """ + Handle TEE evidence request from validator. + This endpoint is called by the validator during Phase 2 to fetch TDX quote and GPU evidence. + """ + try: + # Get the nonce from module-level storage + nonce = _get_tee_nonce() + if nonce is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="No nonce found." + ) + + # Request evidence from attestation service + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + connector = aiohttp.TCPConnector(ssl=ssl_context) + + url = "https://attestation-service-internal.attestation-system.svc.cluster.local.:8443/server/attest" + params = { + "nonce": nonce, + "gpu_ids": os.environ.get("CHUTES_NVIDIA_DEVICES"), + } + + async with aiohttp.ClientSession(connector=connector, raise_for_status=True) as http_session: + async with http_session.get(url, params=params) as resp: + logger.success("Successfully retrieved attestation evidence for validator request.") + evidence = await resp.json() + + # Return evidence with nonce for validator to verify it's the same pod + return { + "evidence": evidence, + "nonce": nonce, + } + except Exception as e: + logger.error(f"Failed to fetch TEE evidence: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to fetch evidence: {str(e)}" + ) From 51a4f546c675f83c8601f98cce4ffeb46bfc9aa5 Mon Sep 17 00:00:00 2001 From: Kyle Widmann Date: Mon, 22 Dec 2025 13:04:14 -0500 Subject: [PATCH 03/17] Update nonce usage for TEE verification --- chutes/entrypoint/verify.py | 133 +++++++++++++++--------------------- 1 file changed, 54 insertions(+), 79 deletions(-) diff --git a/chutes/entrypoint/verify.py b/chutes/entrypoint/verify.py index 40e9cf5..7196664 100644 --- a/chutes/entrypoint/verify.py +++ b/chutes/entrypoint/verify.py @@ -1,6 +1,6 @@ from abc import abstractmethod import base64 -from contextlib import asynccontextmanager, contextmanager +from contextlib import asynccontextmanager from functools import lru_cache import json import os @@ -26,10 +26,18 @@ _evidence_nonce_locked: bool = False -def _set_evidence_nonce(nonce: str) -> None: +@asynccontextmanager +async def _use_evidence_nonce(validator_url: str): """ - Set the TEE nonce. Can only be called once per verification process. + Context manager for TEE evidence nonce lifecycle. Fetches nonce from validator, + makes it available for the duration, and automatically clears it when done. + Args: + validator_url: The base URL of the validator + + Yields: + The nonce value + Raises: RuntimeError: If nonce is already locked (multiple verification processes detected) """ @@ -37,37 +45,51 @@ def _set_evidence_nonce(nonce: str) -> None: if _evidence_nonce_locked: raise RuntimeError( - "TEE nonce already set. Only one verification process should be running." + "TEE nonce already locked. Only one verification process should be running." ) + # Fetch nonce from validator + url = urljoin(validator_url, "/instances/nonce") + async with aiohttp.ClientSession(raise_for_status=True) as http_session: + async with http_session.get(url) as resp: + logger.success(f"Successfully initiated attestation with validator {validator_url}.") + nonce = await resp.json() + + # Set the nonce and lock _evidence_nonce = nonce _evidence_nonce_locked = True - - -@contextmanager -def _use_evidence_nonce(): - """ - Context manager for using the TEE nonce. Ensures nonce is available for the duration - and automatically clears it when done. - - Yields: - The current nonce value - - Raises: - RuntimeError: If no nonce has been set - """ - global _evidence_nonce, _evidence_nonce_locked - - if _evidence_nonce is None: - raise RuntimeError("No nonce has been set") try: - yield _evidence_nonce + yield nonce finally: + # Clean up nonce state _evidence_nonce = None _evidence_nonce_locked = False +def _get_evidence_nonce() -> str | None: + """Get the current evidence nonce (used by evidence endpoint).""" + return _evidence_nonce + + +@asynccontextmanager +async def _attestation_session(): + """ + Creates an aiohttp session configured for the attestation service. + + SSL verification is disabled because certificate authenticity is verified + through TDX quotes, which include a hash of the service's public key. + """ + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + + connector = aiohttp.TCPConnector(ssl=ssl_context) + + async with aiohttp.ClientSession(connector=connector, raise_for_status=True) as session: + yield session + + class GpuVerifier: def __init__(self, body: dict): self._token = get_launch_token() @@ -205,23 +227,6 @@ def gather_gpus(self): class TeeGpuVerifier(GpuVerifier): - @asynccontextmanager - async def _attestation_session(self): - """ - Creates an aiohttp session configured for the attestation service. - - SSL verification is disabled because certificate authenticity is verified - through TDX quotes, which include a hash of the service's public key. - """ - ssl_context = ssl.create_default_context() - ssl_context.check_hostname = False - ssl_context.verify_mode = ssl.CERT_NONE - - connector = aiohttp.TCPConnector(ssl=ssl_context) - - async with aiohttp.ClientSession(connector=connector, raise_for_status=True) as session: - yield session - @property @lru_cache(maxsize=1) def validator_url(self) -> str: @@ -235,26 +240,6 @@ def deployment_id(self) -> str: _deployment_id = hostname.lstrip("chute-") return _deployment_id - async def _get_nonce(self): - url = urljoin(self.validator_url, "/servers/nonce") - async with aiohttp.ClientSession(raise_for_status=True) as http_session: - async with http_session.get(url) as resp: - logger.success("Successfully retrieved nonce for attestation evidence.") - data = await resp.json() - return data["nonce"] - - async def _fetch_evidence_nonce(self): - """ - Initiate the attestation process with the validator. - """ - url = urljoin(self.validator_url, "/instances/attest") - - async with aiohttp.ClientSession(raise_for_status=True) as http_session: - async with http_session.get(url) as resp: - logger.success(f"Successfully initiated attestation with validator {self.validator_url}.") - _nonce = await resp.json() - _set_evidence_nonce(_nonce) - async def fetch_symmetric_key(self): """ New TEE verification flow (3 phases): @@ -263,24 +248,19 @@ async def fetch_symmetric_key(self): validator verifies and returns symmetric key Phase 3: Start dummy sockets and finalize verification (handled by base class) """ - # First get a nonce from the validator to use with attestation evidence - await self._fetch_evidence_nonce() - - # Claim the launch config - validator will call our /_tee_evidence endpoint - # and return symmetric key after successful attestation token = self._token - # Use context manager to ensure nonce is available for evidence endpoint and cleaned up after - with _use_evidence_nonce() as nonce: + # Context manager fetches nonce, makes it available for evidence endpoint, and cleans up + async with _use_evidence_nonce(self.validator_url) as _nonce: async with aiohttp.ClientSession(raise_for_status=True) as session: headers = { "Authorization": token, - "X-Chutes-Nonce": nonce, + "X-Chutes-Nonce": _nonce } _body = self._body.copy() _body["deployment_id"] = self.deployment_id logger.info(f"Requesting verification from validator: {self._url}") - async with session.post(self._url, headers=headers, json=self._body) as resp: + async with session.post(self._url, headers=headers, json=_body) as resp: data = await resp.json() self._symmetric_key = bytes.fromhex(data["symmetric_key"]) logger.success("Successfully received symmetric key from validator") @@ -315,7 +295,7 @@ async def finalize_verification(self): async def gather_gpus(self): devices = [] - async with self._attestation_session() as http_session: + async with _attestation_session() as http_session: url = "https://attestation-service-internal.attestation-system.svc.cluster.local.:8443/server/devices" params = {"gpu_ids": os.environ.get("CHUTES_NVIDIA_DEVICES")} async with http_session.get(url=url, params=params) as resp: @@ -405,27 +385,22 @@ async def tee_evidence_endpoint(request: Request): This endpoint is called by the validator during Phase 2 to fetch TDX quote and GPU evidence. """ try: - # Get the nonce from module-level storage - nonce = _get_tee_nonce() + # Get the nonce from module-level storage (already set by fetch_symmetric_key) + nonce = _get_evidence_nonce() if nonce is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="No nonce found." + detail="No nonce found. Attestation not initiated." ) # Request evidence from attestation service - ssl_context = ssl.create_default_context() - ssl_context.check_hostname = False - ssl_context.verify_mode = ssl.CERT_NONE - connector = aiohttp.TCPConnector(ssl=ssl_context) - url = "https://attestation-service-internal.attestation-system.svc.cluster.local.:8443/server/attest" params = { "nonce": nonce, "gpu_ids": os.environ.get("CHUTES_NVIDIA_DEVICES"), } - async with aiohttp.ClientSession(connector=connector, raise_for_status=True) as http_session: + async with _attestation_session() as http_session: async with http_session.get(url, params=params) as resp: logger.success("Successfully retrieved attestation evidence for validator request.") evidence = await resp.json() From ed1ed684313ef8412c1d5f6b3f1d7ace7c642e2a Mon Sep 17 00:00:00 2001 From: Kyle Widmann Date: Sun, 25 Jan 2026 08:10:14 -0500 Subject: [PATCH 04/17] Add stream log to warmup --- chutes/entrypoint/warmup.py | 163 ++++++++++++++++++++++++++++++++---- 1 file changed, 147 insertions(+), 16 deletions(-) diff --git a/chutes/entrypoint/warmup.py b/chutes/entrypoint/warmup.py index 190b0fd..a67df01 100644 --- a/chutes/entrypoint/warmup.py +++ b/chutes/entrypoint/warmup.py @@ -6,6 +6,8 @@ import asyncio import aiohttp import orjson as json +import sys +import time from loguru import logger import typer from chutes.config import get_config @@ -13,6 +15,117 @@ from chutes.util.auth import sign_request +async def poll_for_instance(chute_name: str, config, headers, poll_interval: float = 2.0, max_wait: float = 600.0): + """ + Poll for instances of a chute. Returns the first instance_id found (regardless of active status). + + Args: + chute_name: Name or ID of the chute + config: Config object + headers: Request headers + poll_interval: Seconds between polls + max_wait: Maximum seconds to wait for an instance (default 10 minutes) + """ + start_time = time.time() + async with aiohttp.ClientSession(base_url=config.generic.api_base_url) as session: + while time.time() - start_time < max_wait: + try: + async with session.get( + f"/chutes/{chute_name}", + headers=headers, + ) as response: + if response.status == 200: + data = await response.json() + instances = data.get("instances", []) + if instances: + # Return the first instance_id found (not just active ones) + instance_id = instances[0].get("instance_id") + if instance_id: + return instance_id + elif response.status == 404: + # Chute doesn't exist - this is an error, not a polling condition + error_text = await response.text() + raise ValueError(f"Chute '{chute_name}' not found: {error_text}") + else: + error_text = await response.text() + logger.debug(f"Failed to get chute (status {response.status}): {error_text}") + except ValueError: + # Re-raise ValueError (chute not found) immediately + raise + except Exception as e: + logger.debug(f"Error polling for instances: {e}") + + await asyncio.sleep(poll_interval) + + # Timeout reached + raise TimeoutError(f"No instances found for chute {chute_name} within {max_wait} seconds") + + +async def stream_instance_logs(instance_id: str, config, headers, backfill: int = 100): + """ + Stream logs from an instance. + """ + async with aiohttp.ClientSession(base_url=config.generic.api_base_url) as session: + try: + async with session.get( + f"/instances/{instance_id}/logs", + headers=headers, + params={"backfill": str(backfill)}, + ) as response: + if response.status == 200: + logger.info(f"Streaming logs from instance {instance_id}...") + # Stream the response content directly to stdout + async for chunk in response.content.iter_any(): + if chunk: + sys.stdout.buffer.write(chunk) + sys.stdout.buffer.flush() + else: + error_text = await response.text() + logger.error(f"Failed to stream logs: {error_text}") + except asyncio.CancelledError: + raise + except Exception as e: + logger.error(f"Error streaming logs: {e}") + + +async def monitor_warmup(chute_name: str, config, headers): + """ + Monitor the warmup stream and log status updates. + """ + async with aiohttp.ClientSession(base_url=config.generic.api_base_url) as session: + async with session.get( + f"/chutes/warmup/{chute_name}", + headers=headers, + ) as response: + if response.status == 200: + async for raw_chunk in response.content: + if raw_chunk.startswith(b"data:"): + chunk = json.loads(raw_chunk[5:]) + if chunk["status"] == "hot": + logger.success(chunk["log"]) + else: + logger.warning(f"Status: {chunk['status']} -- {chunk['log']}") + else: + logger.error(await response.text()) + + +async def poll_and_stream_logs(chute_name: str, config, headers): + """ + Poll for instances and stream logs when found. + """ + try: + instance_id = await poll_for_instance(chute_name, config, headers) + logger.info(f"Found instance {instance_id}, starting log stream...") + # Stream logs - this will continue until interrupted or stream ends + await stream_instance_logs(instance_id, config, headers) + except asyncio.CancelledError: + pass + except TimeoutError as e: + logger.warning(str(e)) + except Exception as e: + logger.error(f"Error in poll_and_stream_logs: {e}") + + def warmup_chute( chute_id_or_ref_str: str = typer.Argument( ..., @@ -22,12 +135,13 @@ def warmup_chute( None, help="Custom path to the chutes config (credentials, API URL, etc.)" ), debug: bool = typer.Option(False, help="enable debug logging"), + stream_logs: bool = typer.Option(False, help="automatically stream logs from the first instance that appears"), ): async def warmup(): """ Do the warmup. """ - nonlocal chute_id_or_ref_str, config_path, debug + nonlocal chute_id_or_ref_str, config_path, debug, stream_logs chute_name = chute_id_or_ref_str if ":" in chute_id_or_ref_str and os.path.exists(chute_id_or_ref_str.split(":")[0] + ".py"): from chutes.chute.base import Chute @@ -38,20 +152,37 @@ async def warmup(): if config_path: os.environ["CHUTES_CONFIG_PATH"] = config_path headers, _ = sign_request(purpose="chutes") - async with aiohttp.ClientSession(base_url=config.generic.api_base_url) as session: - async with session.get( - f"/chutes/warmup/{chute_name}", - headers=headers, - ) as response: - if response.status == 200: - async for raw_chunk in response.content: - if raw_chunk.startswith(b"data:"): - chunk = json.loads(raw_chunk[5:]) - if chunk["status"] == "hot": - logger.success(chunk["log"]) - else: - logger.warning(f"Status: {chunk['status']} -- {chunk['log']}") - else: - logger.error(await response.text()) + + if stream_logs: + # Run warmup monitoring and log streaming in parallel + warmup_task = asyncio.create_task(monitor_warmup(chute_name, config, headers)) + poll_task = asyncio.create_task(poll_and_stream_logs(chute_name, config, headers)) + + # Wait for both tasks, but log streaming should continue even after warmup completes + try: + # Wait for warmup to complete (or be cancelled) + try: + await warmup_task + except Exception as e: + logger.debug(f"Warmup task ended: {e}") + + # Log streaming continues independently - wait for it or user interrupt + try: + await poll_task + except asyncio.CancelledError: + pass + except Exception as e: + logger.debug(f"Poll task ended: {e}") + except KeyboardInterrupt: + warmup_task.cancel() + poll_task.cancel() + try: + await asyncio.gather(warmup_task, poll_task, return_exceptions=True) + except Exception: + pass + raise + else: + # Just monitor warmup + await monitor_warmup(chute_name, config, headers) return asyncio.run(warmup()) From f427aceacd387906a161be68b6385f8df9fe33f7 Mon Sep 17 00:00:00 2001 From: Kyle Widmann Date: Sun, 25 Jan 2026 13:16:35 -0500 Subject: [PATCH 05/17] Add back gpus to request body --- chutes/entrypoint/verify.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/chutes/entrypoint/verify.py b/chutes/entrypoint/verify.py index 7196664..d10a8ed 100644 --- a/chutes/entrypoint/verify.py +++ b/chutes/entrypoint/verify.py @@ -250,6 +250,9 @@ async def fetch_symmetric_key(self): """ token = self._token + # Gather GPUs before sending request + gpus = await self.gather_gpus() + # Context manager fetches nonce, makes it available for evidence endpoint, and cleans up async with _use_evidence_nonce(self.validator_url) as _nonce: async with aiohttp.ClientSession(raise_for_status=True) as session: @@ -259,6 +262,7 @@ async def fetch_symmetric_key(self): } _body = self._body.copy() _body["deployment_id"] = self.deployment_id + _body["gpus"] = gpus logger.info(f"Requesting verification from validator: {self._url}") async with session.post(self._url, headers=headers, json=_body) as resp: data = await resp.json() From e5c01a278a0dc19cccd18e696c5755f808abf264 Mon Sep 17 00:00:00 2001 From: Kyle Widmann Date: Sun, 25 Jan 2026 15:34:56 -0500 Subject: [PATCH 06/17] Cleanup log stream output and auth fixes --- chutes/entrypoint/warmup.py | 195 ++++++++++++++++++++++++++++++------ 1 file changed, 163 insertions(+), 32 deletions(-) diff --git a/chutes/entrypoint/warmup.py b/chutes/entrypoint/warmup.py index a67df01..aeb058d 100644 --- a/chutes/entrypoint/warmup.py +++ b/chutes/entrypoint/warmup.py @@ -15,9 +15,9 @@ from chutes.util.auth import sign_request -async def poll_for_instance(chute_name: str, config, headers, poll_interval: float = 2.0, max_wait: float = 600.0): +async def poll_for_instances(chute_name: str, config, headers, poll_interval: float = 2.0, max_wait: float = 600.0): """ - Poll for instances of a chute. Returns the first instance_id found (regardless of active status). + Poll for instances of a chute. Returns a list of instance info dicts (with instance_id and status). Args: chute_name: Name or ID of the chute @@ -25,6 +25,9 @@ async def poll_for_instance(chute_name: str, config, headers, poll_interval: flo headers: Request headers poll_interval: Seconds between polls max_wait: Maximum seconds to wait for an instance (default 10 minutes) + + Returns: + List of dicts with 'instance_id' and status information """ start_time = time.time() async with aiohttp.ClientSession(base_url=config.generic.api_base_url) as session: @@ -38,10 +41,20 @@ async def poll_for_instance(chute_name: str, config, headers, poll_interval: flo data = await response.json() instances = data.get("instances", []) if instances: - # Return the first instance_id found (not just active ones) - instance_id = instances[0].get("instance_id") - if instance_id: - return instance_id + # Return all instances with their status info + instance_infos = [ + { + "instance_id": inst.get("instance_id"), + "active": inst.get("active", False), + "verified": inst.get("verified", False), + "region": inst.get("region", "n/a"), + "last_verified_at": inst.get("last_verified_at"), + } + for inst in instances + if inst.get("instance_id") + ] + if instance_infos: + return instance_infos elif response.status == 404: # Chute doesn't exist - this is an error, not a polling condition error_text = await response.text() @@ -61,31 +74,88 @@ async def poll_for_instance(chute_name: str, config, headers, poll_interval: flo raise TimeoutError(f"No instances found for chute {chute_name} within {max_wait} seconds") -async def stream_instance_logs(instance_id: str, config, headers, backfill: int = 100): +async def stream_instance_logs(instance_id: str, config, backfill: int = 100): """ Stream logs from an instance. + + Raises: + aiohttp.ClientResponseError: If the request fails + Exception: For other streaming errors """ + # Sign the request with purpose for instances endpoint + headers, _ = sign_request(purpose="logs") async with aiohttp.ClientSession(base_url=config.generic.api_base_url) as session: - try: - async with session.get( - f"/instances/{instance_id}/logs", - headers=headers, - params={"backfill": str(backfill)}, - ) as response: - if response.status == 200: - logger.info(f"Streaming logs from instance {instance_id}...") - # Stream the response content directly to stdout - async for chunk in response.content.iter_any(): - if chunk: - sys.stdout.buffer.write(chunk) - sys.stdout.buffer.flush() - else: - error_text = await response.text() - logger.error(f"Failed to stream logs: {error_text}") - except asyncio.CancelledError: - raise - except Exception as e: - logger.error(f"Error streaming logs: {e}") + async with session.get( + f"/instances/{instance_id}/logs", + headers=headers, + params={"backfill": str(backfill)}, + ) as response: + if response.status != 200: + error_text = await response.text() + raise aiohttp.ClientResponseError( + request_info=response.request_info, + history=response.history, + status=response.status, + message=f"Failed to stream logs from instance {instance_id}: {error_text}", + ) + + logger.info(f"Streaming logs from instance {instance_id}...") + # Parse SSE format and extract log content + buffer = b"" + skipped_lines_count = 0 + try: + async for chunk in response.content.iter_any(): + if chunk: + buffer += chunk + # Process complete lines + while b"\n" in buffer: + line, buffer = buffer.split(b"\n", 1) + # Skip empty lines completely + if not line.strip(): + skipped_lines_count += 1 + continue + # Skip SSE comment lines (lines starting with :) + if line.startswith(b":"): + skipped_lines_count += 1 + continue + # Only process SSE data lines - ignore everything else + if line.startswith(b"data: "): + # Check for empty data: lines (just "data: " or "data:") + data_content = line[6:].strip() if len(line) > 6 else b"" + if not data_content: + skipped_lines_count += 1 + continue + try: + # Parse JSON from SSE data line + data = json.loads(data_content) + log_message = data.get("log", "") + # Only output if we have actual non-empty log content + # Also check that it's not just a single character like "." + if log_message and log_message.strip() and len(log_message.strip()) > 1: + # Write just the log message with a newline + sys.stdout.buffer.write(log_message.encode("utf-8") + b"\n") + sys.stdout.buffer.flush() + elif log_message and log_message.strip(): + # Single character - likely a keepalive, skip it + skipped_lines_count += 1 + logger.debug(f"Skipping single-character log message (likely keepalive): {repr(log_message)}") + else: + skipped_lines_count += 1 + except (json.JSONDecodeError, KeyError) as e: + # Log what we're skipping for debugging + logger.debug(f"Skipping unparseable SSE line: {line[:100]}, error: {e}") + skipped_lines_count += 1 + else: + # Skip non-SSE lines silently (likely keepalive messages) + skipped_lines_count += 1 + except asyncio.CancelledError: + raise + except (aiohttp.ClientError, ConnectionError, OSError) as e: + # Connection errors - instance might have been deleted + raise Exception(f"Stream ended for instance {instance_id} (instance may have been deleted): {e}") from e + except Exception as e: + # Re-raise to allow caller to handle (e.g., try another instance) + raise Exception(f"Error streaming logs from instance {instance_id}: {e}") from e async def monitor_warmup(chute_name: str, config, headers): @@ -111,19 +181,80 @@ async def monitor_warmup(chute_name: str, config, headers): async def poll_and_stream_logs(chute_name: str, config, headers): """ - Poll for instances and stream logs when found. + Poll for instances and stream logs when found. Tries multiple instances if one fails. """ try: - instance_id = await poll_for_instance(chute_name, config, headers) - logger.info(f"Found instance {instance_id}, starting log stream...") - # Stream logs - this will continue until interrupted or stream ends - await stream_instance_logs(instance_id, config, headers) + instance_infos = await poll_for_instances(chute_name, config, headers) + logger.info(f"Found {len(instance_infos)} instance(s), attempting to stream logs...") + + + # Try each instance until one works + # Keep trying instances even if they get deleted during streaming + tried_instance_ids = set() + max_retries = 10 # Limit retries to avoid infinite loops + + for attempt in range(max_retries): + # Refresh instance list in case instances were deleted + try: + current_instance_infos = await poll_for_instances(chute_name, config, headers, poll_interval=1.0, max_wait=5.0) + # Filter out instances we've already tried + available_instances = [ + inst for inst in current_instance_infos + if inst["instance_id"] not in tried_instance_ids + ] + + if not available_instances: + # No new instances available + if tried_instance_ids: + if attempt < max_retries - 1: + logger.info("All instances have been tried or deleted. Waiting for new instances...") + await asyncio.sleep(2.0) + continue + else: + raise Exception("No new instances available after retries") + else: + raise Exception("No instances available for log streaming") + + # Try the first available instance + inst_info = available_instances[0] + instance_id = inst_info["instance_id"] + tried_instance_ids.add(instance_id) + + logger.info(f"Attempting to stream logs from instance {instance_id}...") + + try: + # Stream logs - this will continue until interrupted or stream ends + await stream_instance_logs(instance_id, config) + # If we get here, streaming completed successfully (unlikely for a stream) + return + except asyncio.CancelledError: + # User interrupted, don't try other instances + raise + except Exception as e: + # Stream ended (instance deleted, connection error, etc.) + logger.warning(f"Stream ended for instance {instance_id}: {e}") + logger.info("Looking for another instance to stream from...") + # Continue the loop to try another instance + continue + except TimeoutError: + # No instances found when refreshing + if tried_instance_ids and attempt < max_retries - 1: + logger.info("No new instances found. Waiting...") + await asyncio.sleep(2.0) + continue + else: + raise + + # Exhausted retries + raise Exception(f"Failed to maintain stream after {max_retries} attempts") + except asyncio.CancelledError: pass except TimeoutError as e: logger.warning(str(e)) except Exception as e: logger.error(f"Error in poll_and_stream_logs: {e}") + raise def warmup_chute( From 924287fcea5a8abfd57dc71d0515a6b7a0b54cba Mon Sep 17 00:00:00 2001 From: Kyle Widmann Date: Sun, 25 Jan 2026 15:58:09 -0500 Subject: [PATCH 07/17] Fix deployment id extraction --- chutes/entrypoint/verify.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/chutes/entrypoint/verify.py b/chutes/entrypoint/verify.py index d10a8ed..46aefb8 100644 --- a/chutes/entrypoint/verify.py +++ b/chutes/entrypoint/verify.py @@ -237,7 +237,15 @@ def validator_url(self) -> str: @lru_cache(maxsize=1) def deployment_id(self) -> str: hostname = os.environ.get("HOSTNAME") - _deployment_id = hostname.lstrip("chute-") + # Pod name format: chute-{deployment_id}-{k8s-suffix} + # Service name format: chute-service-{deployment_id} + # We need to extract just the deployment_id by removing the prefix and k8s suffix + if not hostname.startswith("chute-"): + raise ValueError(f"Unexpected hostname format: {hostname}") + # Remove 'chute-' prefix + _deployment_id = hostname[6:] # len("chute-") = 6 + # Remove k8s-generated pod suffix (everything after the last hyphen) + _deployment_id = _deployment_id.rsplit("-", 1)[0] return _deployment_id async def fetch_symmetric_key(self): From 4a0a30a55444077b6b1650bbec160e8c2b07f35e Mon Sep 17 00:00:00 2001 From: Kyle Widmann Date: Sun, 25 Jan 2026 19:12:05 -0500 Subject: [PATCH 08/17] Update to add server for attestation evidence --- chutes/entrypoint/run.py | 4 +- chutes/entrypoint/verify.py | 106 +++++++++++++++++++++++++++++------- 2 files changed, 88 insertions(+), 22 deletions(-) diff --git a/chutes/entrypoint/run.py b/chutes/entrypoint/run.py index 04f5797..eb085dd 100644 --- a/chutes/entrypoint/run.py +++ b/chutes/entrypoint/run.py @@ -1403,9 +1403,7 @@ async def _handle_hf_check(request: Request): chute.add_api_route("/_hf_check", _handle_hf_check, methods=["POST"]) - if is_tee_env(): - chute.add_api_route(TEE_EVIDENCE_ENDPOINT, tee_evidence_endpoint, methods=["GET"]) - + logger.success("Added all chutes internal endpoints.") diff --git a/chutes/entrypoint/verify.py b/chutes/entrypoint/verify.py index 46aefb8..352e7b3 100644 --- a/chutes/entrypoint/verify.py +++ b/chutes/entrypoint/verify.py @@ -1,4 +1,5 @@ from abc import abstractmethod +import asyncio import base64 from contextlib import asynccontextmanager from functools import lru_cache @@ -12,7 +13,8 @@ import aiohttp from loguru import logger -from fastapi import Request, HTTPException, status +from fastapi import FastAPI, Request, HTTPException, status +from uvicorn import Config, Server from chutes.entrypoint._shared import encrypt_response, get_launch_token, get_launch_token_data, is_tee_env, miner @@ -225,8 +227,72 @@ def gather_gpus(self): return gpus -class TeeGpuVerifier(GpuVerifier): +class TeeAttestationService: + """ + Context manager for TEE attestation evidence server. + + Starts a minimal FastAPI server to serve the /_tee_evidence endpoint during verification. + This is similar to how dummy sockets are started for port validation in Graval flow. + + The server runs on a dedicated port (8002 by default, configurable via CHUTES_TEE_EVIDENCE_PORT) + to isolate it from the main application port. This allows network policies to restrict access + to only the proxy service in the attestation-system namespace. + """ + + def __init__(self): + self._server: Server | None = None + self._server_task: asyncio.Task | None = None + + async def __aenter__(self): + """Start the evidence server.""" + # Use dedicated evidence port (default 8002) for security isolation + # This port should be restricted via network policy to only allow ingress from + # the proxy service in the attestation-system namespace + evidence_port = os.getenv("CHUTES_TEE_EVIDENCE_PORT", "8002") + if not evidence_port.isdigit(): + raise ValueError(f"CHUTES_TEE_EVIDENCE_PORT must be a valid port number, got: {evidence_port}") + evidence_port = int(evidence_port) + + # Create minimal FastAPI app with only the evidence endpoint + evidence_app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None) + evidence_app.add_api_route(TEE_EVIDENCE_ENDPOINT, tee_evidence_endpoint, methods=["GET"]) + + # Start server in background + config = Config( + app=evidence_app, + host="0.0.0.0", + port=evidence_port, + limit_concurrency=1000, + log_level="warning", # Reduce logging noise during verification + ) + self._server = Server(config) + + async def run_evidence_server(): + await self._server.serve() + + self._server_task = asyncio.create_task(run_evidence_server()) + # Give the server a moment to start listening + await asyncio.sleep(0.5) + logger.info(f"Started evidence server on port {evidence_port} for TEE verification") + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + """Stop the evidence server.""" + if self._server is not None and self._server_task is not None: + logger.info("Stopping evidence server...") + self._server.should_exit = True + try: + await asyncio.wait_for(self._server_task, timeout=2.0) + except asyncio.TimeoutError: + logger.warning("Evidence server did not stop within timeout") + except Exception as e: + logger.warning(f"Error stopping evidence server: {e}") + finally: + self._server = None + self._server_task = None + +class TeeGpuVerifier(GpuVerifier): @property @lru_cache(maxsize=1) def validator_url(self) -> str: @@ -251,7 +317,7 @@ def deployment_id(self) -> str: async def fetch_symmetric_key(self): """ New TEE verification flow (3 phases): - Phase 1: Get nonce from validator + Phase 1: Start evidence server and get nonce from validator Phase 2: Validator calls our /_tee_evidence endpoint, we fetch evidence from attestation service, validator verifies and returns symmetric key Phase 3: Start dummy sockets and finalize verification (handled by base class) @@ -261,22 +327,24 @@ async def fetch_symmetric_key(self): # Gather GPUs before sending request gpus = await self.gather_gpus() - # Context manager fetches nonce, makes it available for evidence endpoint, and cleans up - async with _use_evidence_nonce(self.validator_url) as _nonce: - async with aiohttp.ClientSession(raise_for_status=True) as session: - headers = { - "Authorization": token, - "X-Chutes-Nonce": _nonce - } - _body = self._body.copy() - _body["deployment_id"] = self.deployment_id - _body["gpus"] = gpus - logger.info(f"Requesting verification from validator: {self._url}") - async with session.post(self._url, headers=headers, json=_body) as resp: - data = await resp.json() - self._symmetric_key = bytes.fromhex(data["symmetric_key"]) - logger.success("Successfully received symmetric key from validator") - return self._symmetric_key + # Start evidence server as context manager - it will automatically stop when done + async with TeeAttestationService(): + # Context manager fetches nonce, makes it available for evidence endpoint, and cleans up + async with _use_evidence_nonce(self.validator_url) as _nonce: + async with aiohttp.ClientSession(raise_for_status=True) as session: + headers = { + "Authorization": token, + "X-Chutes-Nonce": _nonce + } + _body = self._body.copy() + _body["deployment_id"] = self.deployment_id + _body["gpus"] = gpus + logger.info(f"Requesting verification from validator: {self._url}") + async with session.post(self._url, headers=headers, json=_body) as resp: + data = await resp.json() + self._symmetric_key = bytes.fromhex(data["symmetric_key"]) + logger.success("Successfully received symmetric key from validator") + return self._symmetric_key async def finalize_verification(self): """ From 2110d3696b3916ff78eb4b17e7f924f01727172a Mon Sep 17 00:00:00 2001 From: Kyle Widmann Date: Mon, 26 Jan 2026 18:52:27 -0500 Subject: [PATCH 09/17] Clean up imports and return values --- chutes/entrypoint/run.py | 9 ++------- chutes/entrypoint/verify.py | 6 ++---- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/chutes/entrypoint/run.py b/chutes/entrypoint/run.py index eb085dd..56508ed 100644 --- a/chutes/entrypoint/run.py +++ b/chutes/entrypoint/run.py @@ -34,8 +34,6 @@ from starlette.middleware.base import BaseHTTPMiddleware from chutes.entrypoint.verify import ( GpuVerifier, - TEE_EVIDENCE_ENDPOINT, - tee_evidence_endpoint, ) from chutes.util.hf import verify_cache, CacheVerificationError from prometheus_client import generate_latest, CONTENT_TYPE_LATEST @@ -43,7 +41,6 @@ from chutes.entrypoint._shared import ( get_launch_token, get_launch_token_data, - is_tee_env, load_chute, miner, authenticate_request, @@ -885,7 +882,7 @@ async def _gather_devices_and_initialize( # Verify GPUs, spin up dummy sockets, and finalize verification. verifier = GpuVerifier.create(body) - symmetric_key, response = await verifier.verify() + response = await verifier.verify() # Derive runint session key from validator's pubkey via ECDH if provided # Key derivation happens entirely in C - key never touches Python memory @@ -905,7 +902,7 @@ async def _gather_devices_and_initialize( else: logger.warning("Failed to derive runint session key - using legacy encryption") - return egress, symmetric_key, response + return egress, response # Run a chute (which can be an async job or otherwise long-running process). @@ -1140,7 +1137,6 @@ async def _run_chute(): # GPU verification plus job fetching. job_data: dict | None = None - symmetric_key: bytes | None = None job_id: str | None = None job_obj: Job | None = None job_method: str | None = None @@ -1153,7 +1149,6 @@ async def _run_chute(): if token: ( allow_external_egress, - symmetric_key, response, ) = await _gather_devices_and_initialize( external_host, diff --git a/chutes/entrypoint/verify.py b/chutes/entrypoint/verify.py index 352e7b3..915eb60 100644 --- a/chutes/entrypoint/verify.py +++ b/chutes/entrypoint/verify.py @@ -120,10 +120,10 @@ async def verify(self): """ Execute full verification flow and spin up dummy sockets for port validation. """ - symmetric_key = await self.fetch_symmetric_key() + await self.fetch_symmetric_key() self._start_dummy_sockets() response = await self.finalize_verification() - return symmetric_key, response + return response @abstractmethod async def fetch_symmetric_key(self) -> bytes: ... @@ -180,7 +180,6 @@ async def fetch_symmetric_key(self): # Now, we can respond to the URL by encrypting a payload with the symmetric key and sending it back. self._response_plaintext = sym_key["response_plaintext"] - return self._symmetric_key async def finalize_verification(self): @@ -344,7 +343,6 @@ async def fetch_symmetric_key(self): data = await resp.json() self._symmetric_key = bytes.fromhex(data["symmetric_key"]) logger.success("Successfully received symmetric key from validator") - return self._symmetric_key async def finalize_verification(self): """ From e63a188a41df8029581fc5b315b3213587bb1a63 Mon Sep 17 00:00:00 2001 From: Kyle Widmann Date: Mon, 26 Jan 2026 18:56:24 -0500 Subject: [PATCH 10/17] Remove duplicate block from rebase --- chutes/entrypoint/run.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/chutes/entrypoint/run.py b/chutes/entrypoint/run.py index 56508ed..be48eb1 100644 --- a/chutes/entrypoint/run.py +++ b/chutes/entrypoint/run.py @@ -884,15 +884,6 @@ async def _gather_devices_and_initialize( verifier = GpuVerifier.create(body) response = await verifier.verify() - # Derive runint session key from validator's pubkey via ECDH if provided - # Key derivation happens entirely in C - key never touches Python memory - validator_pubkey = response.get("validator_pubkey") - if validator_pubkey: - if runint_derive_session_key(validator_pubkey): - logger.success("Derived runint session key via ECDH (key never in Python)") - else: - logger.warning("Failed to derive runint session key - using legacy encryption") - # Derive runint session key from validator's pubkey via ECDH if provided # Key derivation happens entirely in C - key never touches Python memory validator_pubkey = response.get("validator_pubkey") From 17e56db791f76dddef8e35fbac081a7b570418a4 Mon Sep 17 00:00:00 2001 From: Kyle Widmann Date: Wed, 28 Jan 2026 21:25:21 -0500 Subject: [PATCH 11/17] Update clients to append env type to url --- chutes/entrypoint/verify.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/chutes/entrypoint/verify.py b/chutes/entrypoint/verify.py index 915eb60..a5175d9 100644 --- a/chutes/entrypoint/verify.py +++ b/chutes/entrypoint/verify.py @@ -142,7 +142,7 @@ def __init__(self, body: dict): async def fetch_symmetric_key(self): # Fetch the challenges. token = self._token - url = self._url + url = urljoin(self._url + "/", "graval") self._body["gpus"] = self.gather_gpus() async with aiohttp.ClientSession(raise_for_status=True) as session: @@ -184,7 +184,7 @@ async def fetch_symmetric_key(self): async def finalize_verification(self): token = self._token - url = self._url + url = urljoin(self._url + "/", "graval") plaintext = self._response_plaintext async with aiohttp.ClientSession(raise_for_status=True) as session: @@ -325,6 +325,8 @@ async def fetch_symmetric_key(self): # Gather GPUs before sending request gpus = await self.gather_gpus() + # Append /tee to instance path; urljoin(base, "/tee") replaces path, urljoin(base, "tee") replaces last segment + url = urljoin(self._url + "/", "tee") # Start evidence server as context manager - it will automatically stop when done async with TeeAttestationService(): @@ -338,8 +340,8 @@ async def fetch_symmetric_key(self): _body = self._body.copy() _body["deployment_id"] = self.deployment_id _body["gpus"] = gpus - logger.info(f"Requesting verification from validator: {self._url}") - async with session.post(self._url, headers=headers, json=_body) as resp: + logger.info(f"Requesting verification from validator: {url}") + async with session.post(url, headers=headers, json=_body) as resp: data = await resp.json() self._symmetric_key = bytes.fromhex(data["symmetric_key"]) logger.success("Successfully received symmetric key from validator") @@ -352,7 +354,8 @@ async def finalize_verification(self): raise RuntimeError("Symmetric key must be fetched before finalizing verification.") token = self._token - url = self._url + # Append /tee to instance path; urljoin(base, "/tee") would replace path with /tee (RFC 3986) + url = urljoin(self._url + "/", "tee") async with aiohttp.ClientSession(raise_for_status=False) as session: logger.info("Sending final verification request.") From 398ddc42944a662094161320526326c222d4a626 Mon Sep 17 00:00:00 2001 From: Kyle Widmann Date: Thu, 5 Feb 2026 10:38:18 -0500 Subject: [PATCH 12/17] Update evidence server to be standalone service --- chutes/entrypoint/verify.py | 258 ++++++++++++++++++++---------------- 1 file changed, 146 insertions(+), 112 deletions(-) diff --git a/chutes/entrypoint/verify.py b/chutes/entrypoint/verify.py index a5175d9..d2ede71 100644 --- a/chutes/entrypoint/verify.py +++ b/chutes/entrypoint/verify.py @@ -5,7 +5,6 @@ from functools import lru_cache import json import os -from re import A import ssl import socket import threading @@ -20,14 +19,16 @@ # TEE endpoint constants -TEE_EVIDENCE_ENDPOINT = "/_tee_evidence" +TEE_VERIFICATION_ENDPOINT = "/verify" # Validator only: uses nonce from fetch_symmetric_key +TEE_EVIDENCE_RUNTIME_ENDPOINT = "/evidence" # Third parties: accept nonce from request -# Global nonce storage for TEE verification -# This should only be set once during the verification process +# Global nonce storage for TEE verification (validator flow only) +# Set during fetch_symmetric_key; used only by /verify to prove same instance _evidence_nonce: str | None = None _evidence_nonce_locked: bool = False + @asynccontextmanager async def _use_evidence_nonce(validator_url: str): """ @@ -226,69 +227,142 @@ def gather_gpus(self): return gpus -class TeeAttestationService: +def _parse_evidence_port() -> int: + """Parse TEE evidence port from env (default 8002).""" + evidence_port = os.getenv("CHUTES_TEE_EVIDENCE_PORT", "8002") + if not evidence_port.isdigit(): + raise ValueError(f"CHUTES_TEE_EVIDENCE_PORT must be a valid port number, got: {evidence_port}") + return int(evidence_port) + + +class TeeEvidenceService: """ - Context manager for TEE attestation evidence server. - - Starts a minimal FastAPI server to serve the /_tee_evidence endpoint during verification. - This is similar to how dummy sockets are started for port validation in Graval flow. - - The server runs on a dedicated port (8002 by default, configurable via CHUTES_TEE_EVIDENCE_PORT) - to isolate it from the main application port. This allows network policies to restrict access - to only the proxy service in the attestation-system namespace. + Singleton TEE attestation evidence server. Serves GET /_tee_evidence for the + validator (during verification) and for third-party runtime verification. """ - + + _instance: "TeeEvidenceService | None" = None + + def __new__(cls) -> "TeeEvidenceService": + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + def __init__(self): - self._server: Server | None = None - self._server_task: asyncio.Task | None = None - - async def __aenter__(self): - """Start the evidence server.""" - # Use dedicated evidence port (default 8002) for security isolation - # This port should be restricted via network policy to only allow ingress from - # the proxy service in the attestation-system namespace - evidence_port = os.getenv("CHUTES_TEE_EVIDENCE_PORT", "8002") - if not evidence_port.isdigit(): - raise ValueError(f"CHUTES_TEE_EVIDENCE_PORT must be a valid port number, got: {evidence_port}") - evidence_port = int(evidence_port) - - # Create minimal FastAPI app with only the evidence endpoint + if not hasattr(self, "_port"): + self._port: int | None = None + self._server: Server | None = None + self._task: asyncio.Task | None = None + + async def start(self) -> dict: + """ + Start the evidence server. Idempotent: if already started, returns the same + port mapping without starting a second server. + + Returns: + Port mapping dict to append to port_mappings in the run entrypoint, e.g.: + {"proto": "tcp", "internal_port": 8002, "external_port": 8002, "default": False} + """ + if self._task is not None: + return self._port_mapping() + self._port = _parse_evidence_port() evidence_app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None) - evidence_app.add_api_route(TEE_EVIDENCE_ENDPOINT, tee_evidence_endpoint, methods=["GET"]) - - # Start server in background + evidence_app.add_api_route(TEE_VERIFICATION_ENDPOINT, self._get_verification_evidence, methods=["GET"]) + evidence_app.add_api_route(TEE_EVIDENCE_RUNTIME_ENDPOINT, self._get_runtime_evidence, methods=["GET"]) config = Config( app=evidence_app, host="0.0.0.0", - port=evidence_port, + port=self._port, limit_concurrency=1000, - log_level="warning", # Reduce logging noise during verification + log_level="warning", ) self._server = Server(config) - - async def run_evidence_server(): - await self._server.serve() - - self._server_task = asyncio.create_task(run_evidence_server()) - # Give the server a moment to start listening + self._task = asyncio.create_task(self._server.serve()) await asyncio.sleep(0.5) - logger.info(f"Started evidence server on port {evidence_port} for TEE verification") - return self - - async def __aexit__(self, exc_type, exc_val, exc_tb): - """Stop the evidence server.""" - if self._server is not None and self._server_task is not None: - logger.info("Stopping evidence server...") - self._server.should_exit = True - try: - await asyncio.wait_for(self._server_task, timeout=2.0) - except asyncio.TimeoutError: - logger.warning("Evidence server did not stop within timeout") - except Exception as e: - logger.warning(f"Error stopping evidence server: {e}") - finally: - self._server = None - self._server_task = None + logger.info(f"Started TEE evidence server on port {self._port}") + return self._port_mapping() + + def _port_mapping(self) -> dict: + """Port mapping dict for this service (internal_port == external_port == configured port).""" + return { + "proto": "tcp", + "internal_port": self._port, + "external_port": self._port, + "default": False, + } + + async def stop(self) -> None: + """Stop the evidence server. No-op if not started.""" + if self._server is None or self._task is None: + return + logger.info("Stopping TEE evidence server...") + self._server.should_exit = True + try: + await asyncio.wait_for(self._task, timeout=2.0) + except asyncio.TimeoutError: + logger.warning("TEE evidence server did not stop within timeout") + except Exception as e: + logger.warning(f"Error stopping TEE evidence server: {e}") + finally: + self._server = None + self._task = None + self._port = None + + async def _fetch_evidence(self, nonce: str) -> dict: + """Request evidence from the attestation service for the given nonce.""" + url = "https://attestation-service-internal.attestation-system.svc.cluster.local.:8443/server/attest" + params = { + "nonce": nonce, + "gpu_ids": os.environ.get("CHUTES_NVIDIA_DEVICES"), + } + async with _attestation_session() as http_session: + async with http_session.get(url, params=params) as resp: + evidence = await resp.json() + return {"evidence": evidence, "nonce": nonce} + + async def _get_verification_evidence(self, request: Request): + """ + TEE evidence for initial verification only. Called by the validator during + fetch_symmetric_key (Phase 2). Uses the nonce retrieved from the validator + when we started the process so we can prove we are the same instance. + """ + nonce = _get_evidence_nonce() + if nonce is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="No nonce found. Attestation not initiated. Use /evidence?nonce=... for third-party evidence.", + ) + try: + logger.success("Retrieved attestation evidence for validator verification.") + return await self._fetch_evidence(nonce) + except Exception as e: + logger.error(f"Failed to fetch TEE evidence: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to fetch evidence: {str(e)}", + ) + + async def _get_runtime_evidence(self, request: Request): + """ + TEE evidence for third-party runtime verification. Caller supplies a nonce + via query param ?nonce=...; we request evidence bound to that nonce and return it. + """ + nonce = request.query_params.get("nonce") + if not nonce or not nonce.strip(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Query parameter 'nonce' is required for runtime evidence.", + ) + nonce = nonce.strip() + try: + logger.success("Retrieved attestation evidence.") + return await self._fetch_evidence(nonce) + except Exception as e: + logger.error(f"Failed to fetch TEE evidence: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to fetch evidence: {str(e)}", + ) class TeeGpuVerifier(GpuVerifier): @@ -315,8 +389,8 @@ def deployment_id(self) -> str: async def fetch_symmetric_key(self): """ - New TEE verification flow (3 phases): - Phase 1: Start evidence server and get nonce from validator + TEE verification flow (3 phases): + Phase 1: Get nonce from validator (evidence server is already running at chute startup) Phase 2: Validator calls our /_tee_evidence endpoint, we fetch evidence from attestation service, validator verifies and returns symmetric key Phase 3: Start dummy sockets and finalize verification (handled by base class) @@ -328,23 +402,20 @@ async def fetch_symmetric_key(self): # Append /tee to instance path; urljoin(base, "/tee") replaces path, urljoin(base, "tee") replaces last segment url = urljoin(self._url + "/", "tee") - # Start evidence server as context manager - it will automatically stop when done - async with TeeAttestationService(): - # Context manager fetches nonce, makes it available for evidence endpoint, and cleans up - async with _use_evidence_nonce(self.validator_url) as _nonce: - async with aiohttp.ClientSession(raise_for_status=True) as session: - headers = { - "Authorization": token, - "X-Chutes-Nonce": _nonce - } - _body = self._body.copy() - _body["deployment_id"] = self.deployment_id - _body["gpus"] = gpus - logger.info(f"Requesting verification from validator: {url}") - async with session.post(url, headers=headers, json=_body) as resp: - data = await resp.json() - self._symmetric_key = bytes.fromhex(data["symmetric_key"]) - logger.success("Successfully received symmetric key from validator") + async with _use_evidence_nonce(self.validator_url) as _nonce: + async with aiohttp.ClientSession(raise_for_status=True) as session: + headers = { + "Authorization": token, + "X-Chutes-Nonce": _nonce + } + _body = self._body.copy() + _body["deployment_id"] = self.deployment_id + _body["gpus"] = gpus + logger.info(f"Requesting verification from validator: {url}") + async with session.post(url, headers=headers, json=_body) as resp: + data = await resp.json() + self._symmetric_key = bytes.fromhex(data["symmetric_key"]) + logger.success("Successfully received symmetric key from validator") async def finalize_verification(self): """ @@ -460,40 +531,3 @@ def udp_handler(): return thread -async def tee_evidence_endpoint(request: Request): - """ - Handle TEE evidence request from validator. - This endpoint is called by the validator during Phase 2 to fetch TDX quote and GPU evidence. - """ - try: - # Get the nonce from module-level storage (already set by fetch_symmetric_key) - nonce = _get_evidence_nonce() - if nonce is None: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="No nonce found. Attestation not initiated." - ) - - # Request evidence from attestation service - url = "https://attestation-service-internal.attestation-system.svc.cluster.local.:8443/server/attest" - params = { - "nonce": nonce, - "gpu_ids": os.environ.get("CHUTES_NVIDIA_DEVICES"), - } - - async with _attestation_session() as http_session: - async with http_session.get(url, params=params) as resp: - logger.success("Successfully retrieved attestation evidence for validator request.") - evidence = await resp.json() - - # Return evidence with nonce for validator to verify it's the same pod - return { - "evidence": evidence, - "nonce": nonce, - } - except Exception as e: - logger.error(f"Failed to fetch TEE evidence: {e}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to fetch evidence: {str(e)}" - ) From 2b5e895d7d9fa15130b3b785475aae92601820a7 Mon Sep 17 00:00:00 2001 From: Kyle Widmann Date: Thu, 5 Feb 2026 10:38:27 -0500 Subject: [PATCH 13/17] Update run entrypoint to start and stop server --- chutes/entrypoint/run.py | 649 +++++++++++++++++++-------------------- 1 file changed, 324 insertions(+), 325 deletions(-) diff --git a/chutes/entrypoint/run.py b/chutes/entrypoint/run.py index be48eb1..b18d0ce 100644 --- a/chutes/entrypoint/run.py +++ b/chutes/entrypoint/run.py @@ -34,6 +34,7 @@ from starlette.middleware.base import BaseHTTPMiddleware from chutes.entrypoint.verify import ( GpuVerifier, + TeeEvidenceService, ) from chutes.util.hf import verify_cache, CacheVerificationError from prometheus_client import generate_latest, CONTENT_TYPE_LATEST @@ -41,6 +42,7 @@ from chutes.entrypoint._shared import ( get_launch_token, get_launch_token_data, + is_tee_env, load_chute, miner, authenticate_request, @@ -1125,341 +1127,338 @@ async def _run_chute(): "default": False, } ) + try: + if is_tee_env(): + port_mappings.append(await TeeEvidenceService().start()) + # GPU verification plus job fetching. + job_data: dict | None = None + job_id: str | None = None + job_obj: Job | None = None + job_method: str | None = None + job_status_url: str | None = None + activation_url: str | None = None + allow_external_egress: bool | None = False + + chute_filename = os.path.basename(chute_ref_str.split(":")[0] + ".py") + chute_abspath: str = os.path.abspath(os.path.join(os.getcwd(), chute_filename)) + if token: + ( + allow_external_egress, + response, + ) = await _gather_devices_and_initialize( + external_host, + port_mappings, + chute_abspath, + inspecto_hash, + ) + job_id = response.get("job_id") + job_method = response.get("job_method") + job_status_url = response.get("job_status_url") + job_data = response.get("job_data") + activation_url = response.get("activation_url") + code = response["code"] + fs_key = response["fs_key"] + encrypted_cache = response.get("efs") is True + if ( + fs_key + and netnanny.set_secure_fs(chute_abspath.encode(), fs_key.encode(), encrypted_cache) + != 0 + ): + logger.error("NetNanny failed to set secure FS, aborting!") + sys.exit(137) + with open(chute_abspath, "w") as outfile: + outfile.write(code) + + # Secret environment variables, e.g. HF tokens for private models. + if response.get("secrets"): + for secret_key, secret_value in response["secrets"].items(): + os.environ[secret_key] = secret_value + + elif not dev: + logger.error("No GraVal token supplied!") + sys.exit(1) + + # Now we have the chute code available, either because it's dev and the file is plain text here, + # or it's prod and we've fetched the code from the validator and stored it securely. + chute_module, chute = load_chute(chute_ref_str=chute_ref_str, config_path=None, debug=debug) + chute = chute.chute if isinstance(chute, ChutePack) else chute + if job_method: + job_obj = next(j for j in chute._jobs if j.name == job_method) + + # Configure dev method job payload/method/etc. + if dev and dev_job_data_path: + with open(dev_job_data_path) as infile: + job_data = json.loads(infile.read()) + job_id = str(uuid.uuid4()) + job_method = dev_job_method + job_obj = next(j for j in chute._jobs if j.name == dev_job_method) + logger.info(f"Creating task, dev mode, for {job_method=}") + + # Run the chute's initialization code. + await chute.initialize() + + # Encryption/rate-limiting middleware setup. + if dev: + chute.add_middleware(DevMiddleware) + else: + chute.add_middleware( + GraValMiddleware, + concurrency=chute.concurrency, + ) + + # Slurps and processes. + async def _handle_slurp(request: Request): + nonlocal chute_module + return await handle_slurp(request, chute_module) + + async def _wait_for_server_ready(timeout: float = 30.0): + """Wait until the server is accepting connections.""" + import socket + start = asyncio.get_event_loop().time() + while (asyncio.get_event_loop().time() - start) < timeout: + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(1) + result = sock.connect_ex(("127.0.0.1", port)) + sock.close() + if result == 0: + return True + except Exception: + pass + await asyncio.sleep(0.1) + return False + + async def _do_activation(): + """Activate after server is listening.""" + if not activation_url: + return + if not await _wait_for_server_ready(): + logger.error("Server failed to start listening") + raise Exception("Server not ready for activation") + activated = False + for attempt in range(10): + if attempt > 0: + await asyncio.sleep(attempt) + try: + async with aiohttp.ClientSession(raise_for_status=False) as session: + async with session.get( + activation_url, headers={"Authorization": token} + ) as resp: + if resp.ok: + logger.success(f"Instance activated: {await resp.text()}") + activated = True + if not dev and not allow_external_egress: + if netnanny.lock_network() != 0: + logger.error("Failed to unlock network") + sys.exit(137) + logger.success("Successfully enabled NetNanny network lock.") + break + logger.error( + f"Instance activation failed: {resp.status=}: {await resp.text()}" + ) + if resp.status == 423: + break + except Exception as e: + logger.error(f"Unexpected error attempting to activate instance: {str(e)}") + if not activated: + logger.error("Failed to activate instance, aborting...") + sys.exit(137) - # GPU verification plus job fetching. - job_data: dict | None = None - job_id: str | None = None - job_obj: Job | None = None - job_method: str | None = None - job_status_url: str | None = None - activation_url: str | None = None - allow_external_egress: bool | None = False - - chute_filename = os.path.basename(chute_ref_str.split(":")[0] + ".py") - chute_abspath: str = os.path.abspath(os.path.join(os.getcwd(), chute_filename)) - if token: - ( - allow_external_egress, - response, - ) = await _gather_devices_and_initialize( - external_host, - port_mappings, - chute_abspath, - inspecto_hash, - ) - job_id = response.get("job_id") - job_method = response.get("job_method") - job_status_url = response.get("job_status_url") - job_data = response.get("job_data") - activation_url = response.get("activation_url") - code = response["code"] - fs_key = response["fs_key"] - encrypted_cache = response.get("efs") is True - if ( - fs_key - and netnanny.set_secure_fs(chute_abspath.encode(), fs_key.encode(), encrypted_cache) - != 0 - ): - logger.error("NetNanny failed to set secure FS, aborting!") - sys.exit(137) - with open(chute_abspath, "w") as outfile: - outfile.write(code) - - # Secret environment variables, e.g. HF tokens for private models. - if response.get("secrets"): - for secret_key, secret_value in response["secrets"].items(): - os.environ[secret_key] = secret_value - - elif not dev: - logger.error("No GraVal token supplied!") - sys.exit(1) - - # Now we have the chute code available, either because it's dev and the file is plain text here, - # or it's prod and we've fetched the code from the validator and stored it securely. - chute_module, chute = load_chute(chute_ref_str=chute_ref_str, config_path=None, debug=debug) - chute = chute.chute if isinstance(chute, ChutePack) else chute - if job_method: - job_obj = next(j for j in chute._jobs if j.name == job_method) - - # Configure dev method job payload/method/etc. - if dev and dev_job_data_path: - with open(dev_job_data_path) as infile: - job_data = json.loads(infile.read()) - job_id = str(uuid.uuid4()) - job_method = dev_job_method - job_obj = next(j for j in chute._jobs if j.name == dev_job_method) - logger.info(f"Creating task, dev mode, for {job_method=}") - - # Run the chute's initialization code. - await chute.initialize() - - # Encryption/rate-limiting middleware setup. - if dev: - chute.add_middleware(DevMiddleware) - else: - chute.add_middleware( - GraValMiddleware, - concurrency=chute.concurrency, - ) - - # Slurps and processes. - async def _handle_slurp(request: Request): - nonlocal chute_module - - return await handle_slurp(request, chute_module) - - async def _wait_for_server_ready(timeout: float = 30.0): - """Wait until the server is accepting connections.""" - import socket - - start = asyncio.get_event_loop().time() - while (asyncio.get_event_loop().time() - start) < timeout: - try: - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(1) - result = sock.connect_ex(("127.0.0.1", port)) - sock.close() - if result == 0: - return True - except Exception: - pass - await asyncio.sleep(0.1) - return False - - async def _do_activation(): - """Activate after server is listening.""" - if not activation_url: - return - - if not await _wait_for_server_ready(): - logger.error("Server failed to start listening") - raise Exception("Server not ready for activation") - - activated = False - for attempt in range(10): - if attempt > 0: - await asyncio.sleep(attempt) - try: - async with aiohttp.ClientSession(raise_for_status=False) as session: - async with session.get( - activation_url, headers={"Authorization": token} - ) as resp: - if resp.ok: - logger.success(f"Instance activated: {await resp.text()}") - activated = True - if not dev and not allow_external_egress: - if netnanny.lock_network() != 0: - logger.error("Failed to unlock network") - sys.exit(137) - logger.success("Successfully enabled NetNanny network lock.") - break - - logger.error( - f"Instance activation failed: {resp.status=}: {await resp.text()}" - ) - if resp.status == 423: - break - - except Exception as e: - logger.error(f"Unexpected error attempting to activate instance: {str(e)}") - if not activated: - logger.error("Failed to activate instance, aborting...") - sys.exit(137) - - @chute.on_event("startup") - async def activate_on_startup(): - asyncio.create_task(_do_activation()) + @chute.on_event("startup") + async def activate_on_startup(): + asyncio.create_task(_do_activation()) - async def _handle_fs_hash_challenge(request: Request): - nonlocal chute_abspath - data = request.state.decrypted - return { - "result": await generate_filesystem_hash( - data["salt"], chute_abspath, mode=data.get("mode", "sparse") - ) - } - - async def _handle_conn_stats(request: Request): - return _conn_stats.get_stats() - - # Validation endpoints. - chute.add_api_route("/_ping", pong, methods=["POST"]) - chute.add_api_route("/_token", get_token, methods=["POST"]) - chute.add_api_route("/_metrics", get_metrics, methods=["GET"]) - chute.add_api_route("/_conn_stats", _handle_conn_stats, methods=["GET"]) - chute.add_api_route("/_slurp", _handle_slurp, methods=["POST"]) - chute.add_api_route("/_procs", get_all_process_info, methods=["GET"]) - chute.add_api_route("/_env_sig", get_env_sig, methods=["POST"]) - chute.add_api_route("/_env_dump", get_env_dump, methods=["POST"]) - chute.add_api_route("/_devices", get_devices, methods=["GET"]) - chute.add_api_route("/_device_challenge", process_device_challenge, methods=["GET"]) - chute.add_api_route("/_fs_challenge", process_fs_challenge, methods=["POST"]) - chute.add_api_route("/_fs_hash", _handle_fs_hash_challenge, methods=["POST"]) - chute.add_api_route("/_connectivity", check_connectivity, methods=["POST"]) - - def _handle_nn(request: Request): - return process_netnanny_challenge(chute, request) - - chute.add_api_route("/_netnanny_challenge", _handle_nn, methods=["POST"]) - - # Runtime integrity challenge endpoint. - def _handle_rint(request: Request): - """Handle runtime integrity challenge.""" - challenge = request.state.decrypted.get("challenge") - if not challenge: - return {"error": "missing challenge"} - result = runint_prove(challenge) - if result is None: - return {"error": "runtime integrity not initialized or not bound"} - signature, epoch = result - return { - "signature": signature, - "epoch": epoch, - } - - chute.add_api_route("/_rint", _handle_rint, methods=["POST"]) - - # New envdump endpoints. - import chutes.envdump as envdump - - chute.add_api_route("/_dump", envdump.handle_dump, methods=["POST"]) - chute.add_api_route("/_sig", envdump.handle_sig, methods=["POST"]) - chute.add_api_route("/_toca", envdump.handle_toca, methods=["POST"]) - chute.add_api_route("/_eslurp", envdump.handle_slurp, methods=["POST"]) - - async def _handle_hf_check(request: Request): - """ - Verify HuggingFace cache integrity. - """ - data = request.state.decrypted - repo_id = data.get("repo_id") - revision = data.get("revision") - full_hash_check = data.get("full_hash_check", False) - - if not repo_id or not revision: + async def _handle_fs_hash_challenge(request: Request): + nonlocal chute_abspath + data = request.state.decrypted return { - "error": True, - "reason": "bad_request", - "message": "repo_id and revision are required", - "repo_id": repo_id, - "revision": revision, + "result": await generate_filesystem_hash( + data["salt"], chute_abspath, mode=data.get("mode", "sparse") + ) } - try: - result = await verify_cache( - repo_id=repo_id, - revision=revision, - full_hash_check=full_hash_check, - ) - result["error"] = False - return result - except CacheVerificationError as e: - return e.to_dict() - - chute.add_api_route("/_hf_check", _handle_hf_check, methods=["POST"]) - - async def _handle_hf_check(request: Request): - """ - Verify HuggingFace cache integrity. - """ - data = request.state.decrypted - repo_id = data.get("repo_id") - revision = data.get("revision") - full_hash_check = data.get("full_hash_check", False) - - if not repo_id or not revision: + async def _handle_conn_stats(request: Request): + return _conn_stats.get_stats() + + # Validation endpoints. + chute.add_api_route("/_ping", pong, methods=["POST"]) + chute.add_api_route("/_token", get_token, methods=["POST"]) + chute.add_api_route("/_metrics", get_metrics, methods=["GET"]) + chute.add_api_route("/_conn_stats", _handle_conn_stats, methods=["GET"]) + chute.add_api_route("/_slurp", _handle_slurp, methods=["POST"]) + chute.add_api_route("/_procs", get_all_process_info, methods=["GET"]) + chute.add_api_route("/_env_sig", get_env_sig, methods=["POST"]) + chute.add_api_route("/_env_dump", get_env_dump, methods=["POST"]) + chute.add_api_route("/_devices", get_devices, methods=["GET"]) + chute.add_api_route("/_device_challenge", process_device_challenge, methods=["GET"]) + chute.add_api_route("/_fs_challenge", process_fs_challenge, methods=["POST"]) + chute.add_api_route("/_fs_hash", _handle_fs_hash_challenge, methods=["POST"]) + chute.add_api_route("/_connectivity", check_connectivity, methods=["POST"]) + + def _handle_nn(request: Request): + return process_netnanny_challenge(chute, request) + + chute.add_api_route("/_netnanny_challenge", _handle_nn, methods=["POST"]) + + # Runtime integrity challenge endpoint. + def _handle_rint(request: Request): + """Handle runtime integrity challenge.""" + challenge = request.state.decrypted.get("challenge") + if not challenge: + return {"error": "missing challenge"} + result = runint_prove(challenge) + if result is None: + return {"error": "runtime integrity not initialized or not bound"} + signature, epoch = result return { - "error": True, - "reason": "bad_request", - "message": "repo_id and revision are required", - "repo_id": repo_id, - "revision": revision, + "signature": signature, + "epoch": epoch, } - try: - result = await verify_cache( - repo_id=repo_id, - revision=revision, - full_hash_check=full_hash_check, - ) - result["error"] = False - return result - except CacheVerificationError as e: - return e.to_dict() - - chute.add_api_route("/_hf_check", _handle_hf_check, methods=["POST"]) - - + chute.add_api_route("/_rint", _handle_rint, methods=["POST"]) + + # New envdump endpoints. + import chutes.envdump as envdump + + chute.add_api_route("/_dump", envdump.handle_dump, methods=["POST"]) + chute.add_api_route("/_sig", envdump.handle_sig, methods=["POST"]) + chute.add_api_route("/_toca", envdump.handle_toca, methods=["POST"]) + chute.add_api_route("/_eslurp", envdump.handle_slurp, methods=["POST"]) + + async def _handle_hf_check(request: Request): + """ + Verify HuggingFace cache integrity. + """ + data = request.state.decrypted + repo_id = data.get("repo_id") + revision = data.get("revision") + full_hash_check = data.get("full_hash_check", False) + + if not repo_id or not revision: + return { + "error": True, + "reason": "bad_request", + "message": "repo_id and revision are required", + "repo_id": repo_id, + "revision": revision, + } - logger.success("Added all chutes internal endpoints.") + try: + result = await verify_cache( + repo_id=repo_id, + revision=revision, + full_hash_check=full_hash_check, + ) + result["error"] = False + return result + except CacheVerificationError as e: + return e.to_dict() + + chute.add_api_route("/_hf_check", _handle_hf_check, methods=["POST"]) + + async def _handle_hf_check(request: Request): + """ + Verify HuggingFace cache integrity. + """ + data = request.state.decrypted + repo_id = data.get("repo_id") + revision = data.get("revision") + full_hash_check = data.get("full_hash_check", False) + + if not repo_id or not revision: + return { + "error": True, + "reason": "bad_request", + "message": "repo_id and revision are required", + "repo_id": repo_id, + "revision": revision, + } - # Job shutdown/kill endpoint. - async def _shutdown(): - nonlocal job_obj, server - if not job_obj: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Job task not found", - ) - logger.warning("Shutdown requested.") - if job_obj and not job_obj.cancel_event.is_set(): - job_obj.cancel_event.set() - server.should_exit = True - return {"ok": True} - - # Jobs can't be started until the full suite of validation tests run, - # so we need to provide an endpoint for the validator to use to kick - # it off. - if job_id: - job_task = None - - async def start_job_with_monitoring(**kwargs): - nonlocal job_task - ssh_process = None - job_task = asyncio.create_task(job_obj.run(job_status_url=job_status_url, **kwargs)) - - async def monitor_job(): - try: - result = await job_task - logger.info(f"Job completed with result: {result}") - except Exception as e: - logger.error(f"Job failed with error: {e}") - finally: - logger.info("Job finished, shutting down server...") - if ssh_process: - try: - ssh_process.terminate() - await asyncio.sleep(0.5) - if ssh_process.poll() is None: - ssh_process.kill() - logger.info("SSH server stopped") - except Exception as e: - logger.error(f"Error stopping SSH server: {e}") - server.should_exit = True - - # If the pod defines SSH access, enable it. - if job_obj.ssh and job_data.get("_ssh_public_key"): - ssh_process = await setup_ssh_access(job_data["_ssh_public_key"]) - - asyncio.create_task(monitor_job()) - - await start_job_with_monitoring(**job_data) - logger.info("Started job!") - - chute.add_api_route("/_shutdown", _shutdown, methods=["POST"]) - logger.info("Added shutdown endpoint") - - # Start the uvicorn process, whether in job mode or not. - config = Config( - app=chute, - host=host or "0.0.0.0", - port=port or 8000, - limit_concurrency=1000, - ssl_certfile=certfile, - ssl_keyfile=keyfile, - ) - server = Server(config) - await server.serve() + try: + result = await verify_cache( + repo_id=repo_id, + revision=revision, + full_hash_check=full_hash_check, + ) + result["error"] = False + return result + except CacheVerificationError as e: + return e.to_dict() + + chute.add_api_route("/_hf_check", _handle_hf_check, methods=["POST"]) + + logger.success("Added all chutes internal endpoints.") + + # Job shutdown/kill endpoint. + async def _shutdown(): + nonlocal job_obj, server + if not job_obj: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Job task not found", + ) + logger.warning("Shutdown requested.") + if job_obj and not job_obj.cancel_event.is_set(): + job_obj.cancel_event.set() + server.should_exit = True + return {"ok": True} + + # Jobs can't be started until the full suite of validation tests run, + # so we need to provide an endpoint for the validator to use to kick + # it off. + if job_id: + job_task = None + + async def start_job_with_monitoring(**kwargs): + nonlocal job_task + ssh_process = None + job_task = asyncio.create_task(job_obj.run(job_status_url=job_status_url, **kwargs)) + + async def monitor_job(): + try: + result = await job_task + logger.info(f"Job completed with result: {result}") + except Exception as e: + logger.error(f"Job failed with error: {e}") + finally: + logger.info("Job finished, shutting down server...") + if ssh_process: + try: + ssh_process.terminate() + await asyncio.sleep(0.5) + if ssh_process.poll() is None: + ssh_process.kill() + logger.info("SSH server stopped") + except Exception as e: + logger.error(f"Error stopping SSH server: {e}") + server.should_exit = True + + # If the pod defines SSH access, enable it. + if job_obj.ssh and job_data.get("_ssh_public_key"): + ssh_process = await setup_ssh_access(job_data["_ssh_public_key"]) + + asyncio.create_task(monitor_job()) + + await start_job_with_monitoring(**job_data) + logger.info("Started job!") + + chute.add_api_route("/_shutdown", _shutdown, methods=["POST"]) + logger.info("Added shutdown endpoint") + + # Start the uvicorn process, whether in job mode or not. + config = Config( + app=chute, + host=host or "0.0.0.0", + port=port or 8000, + limit_concurrency=1000, + ssl_certfile=certfile, + ssl_keyfile=keyfile, + ) + server = Server(config) + await server.serve() + finally: + if is_tee_env(): + await TeeEvidenceService().stop() # Kick everything off async def _logged_run(): From 3a4e33c6c880c88ad508eb737d892e03715c88c8 Mon Sep 17 00:00:00 2001 From: Kyle Widmann Date: Fri, 6 Feb 2026 23:22:35 -0500 Subject: [PATCH 14/17] Version bump --- chutes/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chutes/_version.py b/chutes/_version.py index 797946c..b372e25 100644 --- a/chutes/_version.py +++ b/chutes/_version.py @@ -1 +1 @@ -version = "0.5.4.rc9" +version = "0.6.0.rc0" From 80f654f2797b9609b5ad6d27fcb27803c749f307 Mon Sep 17 00:00:00 2001 From: Kyle Widmann Date: Sat, 7 Feb 2026 17:40:34 -0500 Subject: [PATCH 15/17] Remove attestation port from port mappings for verification tests --- chutes/entrypoint/run.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/chutes/entrypoint/run.py b/chutes/entrypoint/run.py index b18d0ce..6eec7da 100644 --- a/chutes/entrypoint/run.py +++ b/chutes/entrypoint/run.py @@ -1129,7 +1129,8 @@ async def _run_chute(): ) try: if is_tee_env(): - port_mappings.append(await TeeEvidenceService().start()) + await TeeEvidenceService().start() + # GPU verification plus job fetching. job_data: dict | None = None job_id: str | None = None From 0771a1bc55e2f78d98f7e3342f24328b5535c04c Mon Sep 17 00:00:00 2001 From: Kyle Widmann Date: Sat, 7 Feb 2026 19:37:53 -0500 Subject: [PATCH 16/17] Provide validator public key for TEE verification --- chutes/entrypoint/verify.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/chutes/entrypoint/verify.py b/chutes/entrypoint/verify.py index d2ede71..e048479 100644 --- a/chutes/entrypoint/verify.py +++ b/chutes/entrypoint/verify.py @@ -415,6 +415,7 @@ async def fetch_symmetric_key(self): async with session.post(url, headers=headers, json=_body) as resp: data = await resp.json() self._symmetric_key = bytes.fromhex(data["symmetric_key"]) + self._validator_pubkey = data["validator_pubkey"] if "validator_pubkey" in data else None logger.success("Successfully received symmetric key from validator") async def finalize_verification(self): @@ -439,7 +440,10 @@ async def finalize_verification(self): ) as resp: if resp.ok: logger.success("Successfully completed final verification!") - return await resp.json() + response = await resp.json() + if self._validator_pubkey: + response["validator_pubkey"] = self._validator_pubkey + return response else: detail = await resp.text(encoding="utf-8", errors="replace") logger.error(f"Final verification failed: {resp.reason} ({resp.status}) {detail}") From 5d847d4805b7954b5f430b4c3df06ef87a83c568 Mon Sep 17 00:00:00 2001 From: Kyle Widmann Date: Sun, 8 Feb 2026 10:25:53 -0500 Subject: [PATCH 17/17] Update chutes to use static IP for attestation service --- chutes/chutes-netnanny.so | Bin 179624 -> 179624 bytes chutes/constants.py | 4 ++++ chutes/entrypoint/verify.py | 5 +++-- 3 files changed, 7 insertions(+), 2 deletions(-) mode change 100755 => 100644 chutes/chutes-netnanny.so diff --git a/chutes/chutes-netnanny.so b/chutes/chutes-netnanny.so old mode 100755 new mode 100644 index 5f7e3a9ec8e1430f953e74b96b0d25917d04bc41..4863516c4d8cbb1ef542133a19049fd33fb3b6ed GIT binary patch delta 79941 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 delta 79395 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

5#@=zlAI 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 None: async def _fetch_evidence(self, nonce: str) -> dict: """Request evidence from the attestation service for the given nonce.""" - url = "https://attestation-service-internal.attestation-system.svc.cluster.local.:8443/server/attest" + url = f"{ATTESTATION_SERVICE_BASE_URL}/server/attest" params = { "nonce": nonce, "gpu_ids": os.environ.get("CHUTES_NVIDIA_DEVICES"), @@ -452,7 +453,7 @@ async def finalize_verification(self): async def gather_gpus(self): devices = [] async with _attestation_session() as http_session: - url = "https://attestation-service-internal.attestation-system.svc.cluster.local.:8443/server/devices" + url = f"{ATTESTATION_SERVICE_BASE_URL}/server/devices" params = {"gpu_ids": os.environ.get("CHUTES_NVIDIA_DEVICES")} async with http_session.get(url=url, params=params) as resp: devices = await resp.json()