"""Trusted Docker-UDS control plane for independent Hermes accounts. Hermes uses host networking for LAN egress with per-account loopback listeners. Only this service may receive the host Docker socket. Bind-source paths must be identical in the control service and Docker host (mount the runtime root there). The official image's root/s6 boot is deliberately bypassed: a nonroot Python bootstrap seeds bundled skills, then execs the verified .venv Hermes CLI. Image compatibility still needs deployment qualification before an image pin is promoted. """ from __future__ import annotations import asyncio import json import os from pathlib import Path import re import secrets import socket import tempfile import yaml import httpx class ProvisioningError(RuntimeError): """Safe-to-display provisioning failure (never includes Docker env/secrets).""" class OwnershipError(ProvisioningError): """An existing resource is not owned by this account.""" # Run as the image's hermes UID without executing root-only s6 startup. No source # checkout/profile is copied: only the image's public bundled skills are seeded. _BOOTSTRAP = """ import os, pathlib, shutil, subprocess, signal, time, ssl home = pathlib.Path('/opt/data') for name in ('home', 'sessions', 'memories', 'skills', 'cron', 'hooks', 'logs', 'cache'): (home / name).mkdir(mode=0o700, parents=True, exist_ok=True) source = pathlib.Path('/opt/hermes/skills') if source.is_dir() and not (home / '.hope-skills-seeded').exists(): shutil.copytree(source, home / 'skills', dirs_exist_ok=True) (home / '.hope-skills-seeded').touch(mode=0o600) os.makedirs('/tmp/hermes-runtime', mode=0o700, exist_ok=True) if os.environ.get('HOPE_WEBUI_ENABLED') != 'true': os.execv('/opt/hermes/.venv/bin/hermes', ['hermes', 'gateway', 'run']) # Generate a private per-account certificate once; never start without usable TLS. from cryptography import x509 from cryptography.x509.oid import NameOID from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import rsa import datetime, ipaddress tls = home / 'webui-tls' tls.mkdir(mode=0o700, exist_ok=True) cert, key = tls / 'cert.pem', tls / 'key.pem' if not cert.exists() and not key.exists(): private = rsa.generate_private_key(public_exponent=65537, key_size=2048) subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, 'Hope Hermes WebUI')]) now = datetime.datetime.now(datetime.timezone.utc) names = [x509.DNSName('localhost'), x509.IPAddress(ipaddress.ip_address('127.0.0.1')), x509.IPAddress(ipaddress.ip_address(os.environ['HERMES_WEBUI_HOST']))] certificate = (x509.CertificateBuilder().subject_name(subject).issuer_name(subject) .public_key(private.public_key()).serial_number(x509.random_serial_number()) .not_valid_before(now - datetime.timedelta(minutes=5)) .not_valid_after(now + datetime.timedelta(days=3650)) .add_extension(x509.SubjectAlternativeName(names), critical=False).sign(private, hashes.SHA256())) key.write_bytes(private.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption())) os.chmod(key, 0o600) cert.write_bytes(certificate.public_bytes(serialization.Encoding.PEM)) ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER).load_cert_chain(str(cert), str(key)) children = [] def stop(signum=None, frame=None): for child in children: if child.poll() is None: child.terminate() signal.signal(signal.SIGTERM, stop) signal.signal(signal.SIGINT, stop) try: children.append(subprocess.Popen(['/opt/hermes/.venv/bin/hermes', 'gateway', 'run'])) children.append(subprocess.Popen(['/opt/hermes/.venv/bin/python', '/opt/hermes-webui/bootstrap.py', '--foreground', '--no-browser', '--skip-agent-install'])) while all(child.poll() is None for child in children): time.sleep(.5) finally: stop() for child in children: try: child.wait(timeout=10) except subprocess.TimeoutExpired: child.kill() child.wait() raise SystemExit(1) """ def container_name(user: dict) -> str: uid, username = user.get('id'), user.get('username') if not isinstance(uid, str) or not re.fullmatch(r'[0-9a-f]{32}', uid): raise ValueError('Account id must be a lowercase UUID hex string') if not isinstance(username, str) or not re.fullmatch(r'[a-z][a-z0-9]*(?:-[a-z0-9]+)*', username) or len(username) > 48: raise ValueError('Account username must be a lowercase slug') return f'hope-hermes-{username}-{uid[:8]}' class Provisioner: def __init__(self, state_dir=None, *, docker_socket=None, image=None, upstream_api_key=None, docker_client=None, health_client=None, ready_timeout=60, poll_interval=.5, runtime_uid=10000, runtime_gid=10000, api_port_range=None, memory_bytes=2 * 1024 ** 3, nano_cpus=2_000_000_000): bounds = api_port_range or os.environ.get('HERMES_API_PORT_RANGE', '20000-29999') try: if isinstance(bounds, str): bounds = tuple(map(int, bounds.split('-'))) if len(bounds) != 2 or any(type(port) is not int for port in bounds) or not 1024 <= bounds[0] <= bounds[1] <= 65535: raise ValueError() except (TypeError, ValueError) as exc: raise ValueError('Invalid Hermes API port range') from exc self.api_port_range = bounds self.state_dir = Path(state_dir or os.environ.get('HOPE_HERMES_DATA_DIR', '.runtime/hermes')).absolute() self.webui_enabled = os.environ.get('HOPE_WEBUI_ENABLED', 'false').lower() == 'true' self.webui_host = os.environ.get('HERMES_WEBUI_HOST', '192.168.86.2') self.image = image or os.environ.get('HERMES_IMAGE', 'nousresearch/hermes-agent@sha256:d4da4a40cd7a28aba983775d9fd31d94cbf153eeb0cb9e844d6d0f612b7c24db') self.upstream_api_key = upstream_api_key if upstream_api_key is not None else os.environ.get('HERMES_UPSTREAM_API_KEY', '') self.ready_timeout, self.poll_interval = ready_timeout, poll_interval self.runtime_uid, self.runtime_gid = runtime_uid, runtime_gid if runtime_uid <= 0 or runtime_gid <= 0: raise ValueError('Hermes must run nonroot') self.memory_bytes, self.nano_cpus = memory_bytes, nano_cpus self._own_docker, self._own_health = docker_client is None, health_client is None # No TCP Docker endpoint, DOCKER_HOST, subprocess, or proxy inheritance. self.docker = docker_client or httpx.AsyncClient( transport=httpx.AsyncHTTPTransport(uds=docker_socket or os.environ.get('DOCKER_SOCKET', '/var/run/docker.sock')), base_url='http://docker', timeout=20, trust_env=False) self.health = health_client or httpx.AsyncClient(timeout=3, trust_env=False, follow_redirects=False) self._locks = {} self._ports_lock = asyncio.Lock() def _ports(self, webui=False): path = self.state_dir / ('webui-ports.json' if webui else 'api-ports.json') if path.is_symlink(): raise ProvisioningError('Port registry cannot be a symlink') try: ports = json.loads(path.read_text()) except FileNotFoundError: return {} if not isinstance(ports, dict) or any( not re.fullmatch(r'[0-9a-f]{32}', uid) or type(port) is not int or not 1024 <= port <= 65535 for uid, port in ports.items()): raise ProvisioningError('Invalid Hermes port registry') if len(set(ports.values())) != len(ports): raise ProvisioningError('Duplicate Hermes API ports') return ports async def _port(self, user, webui=False): # One control process owns this registry, including concurrent accounts. async with self._ports_lock: ports = self._ports(webui) if user['id'] in ports: return ports[user['id']] used = set(ports.values()) | set(self._ports(not webui).values()) bounds = tuple(map(int, os.environ.get('HERMES_WEBUI_PORT_RANGE', '30000-30999').split('-'))) if webui else self.api_port_range if len(bounds) != 2 or not 1024 <= bounds[0] <= bounds[1] <= 65535: raise ProvisioningError('Invalid WebUI port range') for port in range(bounds[0], bounds[1] + 1): if port in used: continue # Control must run in the Docker host network namespace. with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: try: probe.bind((self.webui_host if webui else '127.0.0.1', port)) except OSError: continue ports[user['id']] = port self._directory(self.state_dir) self._write_private(self.state_dir / ('webui-ports.json' if webui else 'api-ports.json'), json.dumps(ports) + '\n') return port raise ProvisioningError('No available Hermes API port') async def aclose(self): if self._own_docker: await self.docker.aclose() if self._own_health: await self.health.aclose() def _lock(self, user): container_name(user) return self._locks.setdefault(user['id'], asyncio.Lock()) async def _request(self, method, path, *, allowed=(200,), **kwargs): try: response = await self.docker.request(method, path, **kwargs) except httpx.HTTPError as exc: raise ProvisioningError('Docker control socket unavailable; retry provisioning') from exc if response.status_code not in allowed: raise ProvisioningError(f'Docker operation failed (HTTP {response.status_code}); retry provisioning') return response async def _inspect(self, name): response = await self._request('GET', f'/containers/{name}/json', allowed=(200, 404)) return response.json() if response.status_code == 200 else None @staticmethod def _owned(resource, user): labels = resource.get('Config', {}).get('Labels') or resource.get('Labels') or {} if labels.get('hope.managed') != 'true' or labels.get('hope.user_id') != user['id']: raise OwnershipError('Refusing resource whose Hope ownership labels do not match') @staticmethod def _directory(path): # Do not follow a user-created symlink into another account's state. if path.is_symlink(): raise ProvisioningError('Private runtime directory cannot be a symlink') path.mkdir(mode=0o700, parents=True, exist_ok=True) os.chmod(path, 0o700) @staticmethod def _write_private(path, value): if path.is_symlink(): raise ProvisioningError('Private runtime file cannot be a symlink') fd, temp = tempfile.mkstemp(prefix='.hope-', dir=path.parent) try: with os.fdopen(fd, 'w') as stream: stream.write(value) stream.flush() os.fsync(stream.fileno()) os.replace(temp, path) finally: if os.path.exists(temp): temporary = Path(temp) if temporary.resolve().parent != path.parent.resolve() or not temporary.name.startswith('.hope-'): raise ProvisioningError('Refusing unexpected temporary-file cleanup path') temporary.unlink() def _paths(self, user): container_name(user) # Every ancestor under the configured root is control-owned, not mounted # into Hermes. HOME cannot access the sibling API credential file. return self.state_dir / user['id'], self.state_dir / user['id'] / 'home', self.state_dir / user['id'] / 'workspace' def _read_key(self, user): private, _, _ = self._paths(user) path = private / 'api-key' if path.is_symlink(): raise ProvisioningError('Private runtime file cannot be a symlink') try: key = path.read_text().strip() except FileNotFoundError: return None if len(key) < 32 or '\n' in key: raise ProvisioningError('Invalid persisted Hermes API credential') return key @staticmethod def _declare_auto_vision(value): """Patch only the verified managed auto model, preserving other metadata.""" providers = value.get('providers', {}) if not isinstance(providers, dict): raise ValueError('Invalid provider configuration') provider = providers.get('hope-upstream') if provider is None: return False if not isinstance(provider, dict): raise ValueError('Invalid managed provider') models = provider.get('models', {}) if isinstance(models, list): if not all(isinstance(model, str) for model in models): raise ValueError('Invalid model list') if 'auto' not in models: return False models = {model: {} for model in models} provider['models'] = models if not isinstance(models, dict): raise ValueError('Invalid model metadata') if 'auto' not in models: return False metadata = models['auto'] if not isinstance(metadata, dict): raise ValueError('Invalid auto metadata') if metadata.get('supports_vision') is True: return False metadata['supports_vision'] = True return True def _reload_marker(self, user): return self._paths(user)[0] / 'config-reload-pending' def _prepare(self, user, exists, port): private, home, workspace = self._paths(user) self._directory(self.state_dir) for path in (private, home, workspace): self._directory(path) key = self._read_key(user) if not key: if exists: raise ProvisioningError('Owned container has no persisted API credential; refusing unsafe adoption') key = secrets.token_urlsafe(48) self._write_private(private / 'api-key', key + '\n') config = home / 'config.yaml' if not config.exists(): # JSON is a YAML subset; no credentials are placed in configuration. self._write_private(config, json.dumps(self.config(port), indent=2) + '\n') elif config.is_symlink(): raise ProvisioningError('Hermes config cannot be a symlink') else: # Preserve user configuration except managed listeners/verified auto capability. try: value = yaml.safe_load(config.read_text()) if not isinstance(value, dict): raise ValueError() server = value.setdefault('gateway', {}).setdefault('api_server', {}) if not isinstance(server, dict): raise ValueError() changed = self._declare_auto_vision(value) if server.get('host') != '127.0.0.1' or server.get('port') != port: server.update(host='127.0.0.1', port=port) changed = True if changed: # Durable before writing: retry must reload even after a crash. self._write_private(self._reload_marker(user), 'pending\n') self._write_private(config, yaml.safe_dump(value, sort_keys=False)) except (ValueError, TypeError, AttributeError, yaml.YAMLError) as exc: raise ProvisioningError('Invalid Hermes runtime configuration') from exc if os.geteuid() == 0: # Only seeded directory roots/config, never recursive retained data. for path in (home, workspace, config): os.chown(path, self.runtime_uid, self.runtime_gid) elif home.stat().st_uid != self.runtime_uid: raise ProvisioningError('Control service must prepare private directories for the Hermes runtime UID') return key, home, workspace @staticmethod def config(port=20000): model = os.environ.get('HERMES_UPSTREAM_MODEL', 'auto') return { 'model': {'default': model, 'provider': 'custom:hope-upstream'}, 'providers': {'hope-upstream': {'api': os.environ.get('HERMES_UPSTREAM_URL', 'http://192.168.86.5:2099/v1'), 'key_env': 'HERMES_UPSTREAM_API_KEY', 'transport': 'codex_responses', 'default_model': model, 'models': {'auto': {'supports_vision': True}} if model == 'auto' else [model], 'discover_models': False}}, 'agent': {'reasoning_effort': 'none'}, 'terminal': {'backend': 'local', 'cwd': '/workspace'}, 'platform_toolsets': {'api_server': ['terminal', 'file', 'web', 'browser', 'memory', 'skills', 'delegation']}, 'gateway': {'multiplex_profiles': False, 'api_server': {'enabled': True, 'host': '127.0.0.1', 'port': port, 'max_concurrent_runs': 1, 'tool_progress_events': True}}, } async def _remove_legacy_network(self, user): name = 'hope-hermes-net-' + user['id'] response = await self._request('GET', f'/networks/{name}', allowed=(200, 404)) if response.status_code == 200: network = response.json() self._owned(network, user) await self._request('DELETE', f'/networks/{network["Id"]}', allowed=(204, 404)) def _spec(self, user, key, home, workspace, port): extra_env = [] if self.webui_enabled: private, _, _ = self._paths(user) password_path = private / 'webui-password' if password_path.is_symlink(): raise ProvisioningError('WebUI password cannot be a symlink') if not password_path.exists(): self._write_private(password_path, secrets.token_urlsafe(32) + '\n') password = password_path.read_text().strip() if not password or '\n' in password or '\r' in password: raise ProvisioningError('WebUI password must be nonempty and single-line') extra_env = ['HOPE_WEBUI_ENABLED=true', f'HERMES_WEBUI_HOST={self.webui_host}', f'HERMES_WEBUI_PORT={self._ports(True)[user["id"]]}', f'HERMES_WEBUI_PASSWORD={password}', f'HERMES_WEBUI_COOKIE_NAME=hope_webui_{user["id"]}', 'HERMES_WEBUI_SECURE=1', 'HERMES_WEBUI_SKIP_ONBOARDING=1', 'HERMES_WEBUI_PRESERVE_ENV=1', 'HERMES_WEBUI_AUTO_INSTALL=0', 'HERMES_WEBUI_SESSION_TTL=86400', 'HERMES_CONFIG_PATH=/opt/data/config.yaml', 'HERMES_WEBUI_DEFAULT_WORKSPACE=/workspace', 'HERMES_WEBUI_SERVER_CWD=/workspace', 'HERMES_WEBUI_STATE_DIR=/opt/data/webui', 'HERMES_WEBUI_TLS_CERT=/opt/data/webui-tls/cert.pem', 'HERMES_WEBUI_TLS_KEY=/opt/data/webui-tls/key.pem', 'HERMES_WEBUI_CHAT_BACKEND=gateway', f'HERMES_WEBUI_GATEWAY_BASE_URL=http://127.0.0.1:{port}', f'HERMES_WEBUI_GATEWAY_API_KEY={key}', 'HERMES_WEBUI_GATEWAY_USE_RUNS_API=true'] return { 'Image': self.image, 'User': f'{self.runtime_uid}:{self.runtime_gid}', 'Entrypoint': ['/opt/hermes/.venv/bin/python'], 'Cmd': ['-c', _BOOTSTRAP], 'WorkingDir': '/workspace', 'Labels': {'hope.managed': 'true', 'hope.user_id': user['id'], **({'hope.webui': '62e4d7b'} if self.webui_enabled else {})}, 'Env': ['HOME=/opt/data/home', 'HERMES_HOME=/opt/data', 'HERMES_GATEWAY_NO_SUPERVISE=1', 'API_SERVER_ENABLED=true', 'API_SERVER_HOST=127.0.0.1', f'API_SERVER_PORT={port}', f'API_SERVER_KEY={key}', f'HERMES_UPSTREAM_API_KEY={self.upstream_api_key}'] + extra_env, 'HostConfig': { 'Binds': [f'{home}:/opt/data:rw', f'{workspace}:/workspace:rw'], 'NetworkMode': 'host', 'Init': True, 'Privileged': False, 'CapDrop': ['ALL'], 'SecurityOpt': ['no-new-privileges:true'], 'PidsLimit': 256, 'Memory': self.memory_bytes, 'MemorySwap': self.memory_bytes, 'NanoCpus': self.nano_cpus, 'ShmSize': 256 * 1024 ** 2, 'RestartPolicy': {'Name': 'unless-stopped'}, 'LogConfig': {'Type': 'json-file', 'Config': {'max-size': '10m', 'max-file': '3'}}, }, } def _connection(self, user, info, key): self._owned(info, user) env = info.get('Config', {}).get('Env') or [] if f'API_SERVER_KEY={key}' not in env: raise ProvisioningError('Owned container API credential does not match private state') if info.get('HostConfig', {}).get('NetworkMode') != 'host': raise ProvisioningError('Hermes requires host networking') port = self._ports().get(user['id']) if not port or f'API_SERVER_HOST=127.0.0.1' not in env or f'API_SERVER_PORT={port}' not in env: raise ProvisioningError('Hermes API must use its assigned loopback listener') return {'container_name': container_name(user), 'hermes_url': f'http://127.0.0.1:{port}', 'hermes_api_key': key} async def _healthy(self, connection): try: response = await self.health.get(connection['hermes_url'] + '/v1/models', headers={'Authorization': 'Bearer ' + connection['hermes_api_key']}) if response.status_code != 200: return False if self.webui_enabled: api_port = int(connection['hermes_url'].rsplit(':', 1)[1]) uid = next((uid for uid, port in self._ports().items() if port == api_port), None) if not uid: return False async with httpx.AsyncClient(verify=False, trust_env=False, timeout=3) as client: webui = await client.get(f'https://{self.webui_host}:{self._ports(True)[uid]}/health') return webui.status_code == 200 and webui.json().get('status') == 'ok' return True except httpx.HTTPError: return False async def ensure(self, user: dict) -> dict: async with self._lock(user): if not self.upstream_api_key: raise ProvisioningError('HERMES_UPSTREAM_API_KEY must be provided at runtime') name = container_name(user) info = await self._inspect(name) if info: self._owned(info, user) port = await self._port(user) if self.webui_enabled: await self._port(user, True) key, home, workspace = self._prepare(user, info is not None, port) # Validate credentials/spec before deleting the previous container. spec = self._spec(user, key, home, workspace, port) if info and (info.get('HostConfig', {}).get('NetworkMode') != 'host' or (self.webui_enabled and info.get('Config', {}).get('Labels', {}).get('hope.webui') != '62e4d7b')): # Verify retained credentials before replacing a legacy container. if f'API_SERVER_KEY={key}' not in (info.get('Config', {}).get('Env') or []): raise ProvisioningError('Owned container API credential does not match private state') await self._request('DELETE', f'/containers/{info["Id"]}', params={'force': 'true', 'v': 'false'}, allowed=(204, 404)) info = None if info is None: await self._request('POST', '/containers/create', params={'name': name}, json=spec, allowed=(201, 409)) info = await self._inspect(name) if info is None: raise ProvisioningError('Docker container creation did not persist; retry') self._owned(info, user) # Never start a host-networked container with an unexpected listener. self._connection(user, info, key) await self._remove_legacy_network(user) marker = self._reload_marker(user) if marker.is_symlink(): raise ProvisioningError('Reload marker cannot be a symlink') if not info.get('State', {}).get('Running'): await self._request('POST', f'/containers/{info["Id"]}/start', allowed=(204, 304)) elif marker.exists(): await self._request('POST', f'/containers/{info["Id"]}/restart', allowed=(204,)) # One overall deadline also bounds Docker inspect / health I/O; no # deletion on failed health, so a retry starts/adopts the same state. try: async with asyncio.timeout(self.ready_timeout): while True: info = await self._inspect(name) if info is None: raise ProvisioningError('Hermes container disappeared; retry') self._owned(info, user) if info.get('State', {}).get('Running'): connection = self._connection(user, info, key) if await self._healthy(connection): if marker.exists(): if marker.is_symlink() or marker.resolve() != self._paths(user)[0].resolve() / 'config-reload-pending': raise ProvisioningError('Invalid reload marker path') marker.unlink() return connection await asyncio.sleep(self.poll_interval) except TimeoutError as exc: raise ProvisioningError('Hermes API readiness timed out; retained container and data for retry') from exc async def ready(self, user: dict) -> bool: try: info = await self._inspect(container_name(user)) if not info or not info.get('State', {}).get('Running'): return False marker = self._reload_marker(user) if marker.exists() or marker.is_symlink(): return False config = self._paths(user)[1] / 'config.yaml' if config.is_symlink(): return False value = yaml.safe_load(config.read_text()) if not isinstance(value, dict) or self._declare_auto_vision(value): return False key = self._read_key(user) return bool(key) and await self._healthy(self._connection(user, info, key)) except (ProvisioningError, ValueError, OSError, yaml.YAMLError): return False async def remove(self, user: dict): async with self._lock(user): info = await self._inspect(container_name(user)) if info: self._owned(info, user) # Operate on inspected immutable ID, never a raced name. Labels # are immutable for the lifetime of that Docker container ID. await self._request('DELETE', f'/containers/{info["Id"]}', params={'force': 'true', 'v': 'false'}, allowed=(204, 404)) await self._remove_legacy_network(user) # Retain port assignments alongside retained keys/data for stable recovery. # Intentionally retain private credential, home, workspace and memory. DockerProvisioner = Provisioner