diff --git a/services/hermes/agent-deployment.yaml b/services/hermes/agent-deployment.yaml index 23f383357..e0b7595e8 100644 --- a/services/hermes/agent-deployment.yaml +++ b/services/hermes/agent-deployment.yaml @@ -736,6 +736,52 @@ spec: resources: requests: {cpu: 50m, memory: 128Mi} limits: {cpu: "1", memory: 1Gi} + - name: codex-broker + image: registry.bstein.dev/bstein/hermes-agent@sha256:b413be5fcc0c01b0dd3e9ae0d54c23314a14f5e58f55da0d7cb47b408a2447e0 + imagePullPolicy: IfNotPresent + command: [/bin/sh, -ec] + args: + - | + set -a + . /opt/data/.env + set +a + exec /opt/hermes/.venv/bin/python /opt/coordinator/codex_broker.py + ports: + - {name: codex-broker, containerPort: 9003, protocol: TCP} + env: + - {name: HERMES_HOME, value: /opt/data} + - {name: HERMES_AUTH_FILE, value: /shared-auth/auth.json} + - {name: HOME, value: /opt/data/home} + - {name: CODEX_HOME, value: /opt/data/home/.codex} + - {name: PYTHONPATH, value: /opt/hermes} + - {name: HERMES_CODEX_BROKER_LISTEN_PORT, value: "9003"} + readinessProbe: + tcpSocket: {port: codex-broker} + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + tcpSocket: {port: codex-broker} + initialDelaySeconds: 30 + periodSeconds: 30 + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: [ALL] + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsUser: 10000 + runAsGroup: 10000 + seccompProfile: + type: RuntimeDefault + volumeMounts: + - {name: home, mountPath: /opt/data} + - {name: provider-auth, mountPath: /shared-auth} + - {name: coordinator, mountPath: /opt/coordinator, readOnly: true} + - {name: codex-runtime-patch, mountPath: /opt/hermes/agent/auxiliary_client.py, subPath: auxiliary_client.py} + - {name: tmp, mountPath: /tmp} + resources: + requests: {cpu: 50m, memory: 128Mi} + limits: {cpu: "1", memory: 1Gi} volumes: - name: home persistentVolumeClaim: diff --git a/services/hermes/chat-configmap.yaml b/services/hermes/chat-configmap.yaml index 597e930e2..e0351d9b2 100644 --- a/services/hermes/chat-configmap.yaml +++ b/services/hermes/chat-configmap.yaml @@ -9,9 +9,16 @@ metadata: data: config.yaml: | model: - provider: openai-codex + provider: atlas-codex default: gpt-5.6-terra model: gpt-5.6-terra + providers: + atlas-codex: + name: Atlas Codex + api: http://hermes-codex-broker.hermes.svc.cluster.local:9003/v1 + key_env: HERMES_IMAGE_BROKER_KEY + default_model: gpt-5.6-terra + transport: codex_responses fallback_providers: - provider: anthropic model: claude-sonnet-5 @@ -53,6 +60,14 @@ data: platform_toolsets: cli: [browser, clarify, delegation, file, image_gen, memory, python_sandbox, session_search, skills, todo, vision, web] api_server: [browser, clarify, delegation, file, image_gen, memory, python_sandbox, session_search, skills, todo, vision, web] + platforms: + api_server: + enabled: true + extra: + model_routes: + gpt-5.6-terra: + provider: atlas-codex + model: gpt-5.6-terra dashboard: public_url: https://chat.hermes.bstein.dev display: diff --git a/services/hermes/kustomization.yaml b/services/hermes/kustomization.yaml index 416da66b5..ed9a44dfb 100644 --- a/services/hermes/kustomization.yaml +++ b/services/hermes/kustomization.yaml @@ -52,6 +52,7 @@ configMapGenerator: - cli_lane_runner.py=scripts/cli_lane_runner.py - codex=scripts/codex - configure_agent_clients.py=scripts/configure_agent_clients.py + - codex_broker.py=scripts/codex_broker.py - gitea_askpass.sh=scripts/gitea_askpass.sh - hermes_coordinator.py=scripts/hermes_coordinator.py - hermes_model_routing.py=scripts/hermes_model_routing.py diff --git a/services/hermes/networkpolicy.yaml b/services/hermes/networkpolicy.yaml index 5c632fb01..786e08ad3 100644 --- a/services/hermes/networkpolicy.yaml +++ b/services/hermes/networkpolicy.yaml @@ -82,6 +82,7 @@ spec: app: hermes-chat-tenant ports: - {protocol: TCP, port: 9002} + - {protocol: TCP, port: 9003} # agent.hermes.bstein.dev is an owner-only engineering workstation. The # browser boundary remains OAuth-protected, while its workers need to reach # every cluster namespace, Atlas LAN service, and hosted provider endpoint. @@ -236,6 +237,7 @@ spec: app: hermes-agent ports: - {protocol: TCP, port: 9002} + - {protocol: TCP, port: 9003} - to: - podSelector: matchLabels: diff --git a/services/hermes/scripts/codex_broker.py b/services/hermes/scripts/codex_broker.py new file mode 100644 index 000000000..bdb32faa1 --- /dev/null +++ b/services/hermes/scripts/codex_broker.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +"""Credential-isolating Codex Responses proxy for Hermes chat tenants.""" + +from __future__ import annotations + +import base64 +import binascii +import hmac +import json +import os +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any + +import httpx + + +HOST = os.environ.get("HERMES_CODEX_BROKER_LISTEN_HOST", "0.0.0.0") +PORT = int(os.environ.get("HERMES_CODEX_BROKER_LISTEN_PORT", "9003")) +TOKEN = os.environ.get("HERMES_IMAGE_BROKER_KEY", "").strip() +UPSTREAM = os.environ.get( + "HERMES_CODEX_BROKER_UPSTREAM", + "https://chatgpt.com/backend-api/codex", +).rstrip("/") +MAX_BODY_BYTES = int(os.environ.get("HERMES_CODEX_BROKER_MAX_BODY", str(64 << 20))) +READ_TIMEOUT_SECONDS = float(os.environ.get("HERMES_CODEX_BROKER_READ_TIMEOUT", "900")) +ALLOWED_MODELS = { + value.strip() + for value in os.environ.get( + "HERMES_CODEX_BROKER_MODELS", + "gpt-5.6-luna,gpt-5.6-sol,gpt-5.6-terra", + ).split(",") + if value.strip() +} + + +def _authorized(header: str | None) -> bool: + """Authenticate a tenant without exposing the relay secret.""" + if not TOKEN or not header or not header.startswith("Bearer "): + return False + return hmac.compare_digest(header[7:].strip(), TOKEN) + + +def _access_token() -> str: + """Read the current owner token; the Codex CLI remains refresh owner.""" + codex_home = Path( + os.environ.get("CODEX_HOME", str(Path.home() / ".codex")) + ).expanduser() + try: + payload = json.loads((codex_home / "auth.json").read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + raise RuntimeError("owner Codex authentication is unavailable") from exc + if not isinstance(payload, dict): + raise RuntimeError("owner Codex authentication is invalid") + tokens = payload.get("tokens") or {} + token = tokens.get("access_token") if isinstance(tokens, dict) else None + if not isinstance(token, str) or not token.strip(): + raise RuntimeError("owner Codex access token is unavailable") + token = token.strip() + try: + encoded = token.split(".")[1] + encoded += "=" * (-len(encoded) % 4) + expires_at = json.loads(base64.urlsafe_b64decode(encoded)).get("exp", 0) + except (IndexError, ValueError, TypeError, json.JSONDecodeError, binascii.Error): + expires_at = 0 + if expires_at and time.time() >= float(expires_at): + raise RuntimeError("owner Codex access token is expired") + return token + + +def _upstream_headers(token: str) -> dict[str, str]: + """Build the first-party headers expected by the Codex backend.""" + from agent.auxiliary_client import _codex_cloudflare_headers + + headers = _codex_cloudflare_headers(token) + headers.update( + { + "Accept": "text/event-stream", + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + ) + return headers + + +def _validate_payload(payload: Any) -> dict[str, Any]: + """Allow only bounded Responses requests for the approved model catalog.""" + if not isinstance(payload, dict): + raise ValueError("JSON object required") + model = payload.get("model") + if not isinstance(model, str) or model not in ALLOWED_MODELS: + raise ValueError("unsupported Codex model") + # Tenant conversations must not enter the owner's server-side history. + payload["store"] = False + payload["stream"] = True + return payload + + +class Handler(BaseHTTPRequestHandler): + """Authenticated streaming proxy; request bodies and tokens are never logged.""" + + server_version = "HermesCodexBroker/1" + + def _json(self, status: int, value: dict[str, Any]) -> None: + body = json.dumps(value, separators=(",", ":")).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + + def _check_auth(self) -> bool: + if _authorized(self.headers.get("Authorization")): + return True + self._json(401, {"error": {"message": "unauthorized", "type": "auth_error"}}) + return False + + def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API + if not self._check_auth(): + return + if self.path == "/health": + try: + _access_token() + except RuntimeError as exc: + self._json(503, {"ok": False, "error": str(exc)}) + return + self._json(200, {"ok": True, "provider": "openai-codex"}) + return + if self.path in {"/models", "/v1/models"}: + self._json( + 200, + { + "object": "list", + "data": [ + {"id": model, "object": "model", "owned_by": "openai-codex"} + for model in sorted(ALLOWED_MODELS) + ], + }, + ) + return + self._json(404, {"error": {"message": "not found", "type": "not_found"}}) + + def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API + if self.path not in {"/responses", "/v1/responses"}: + self._json(404, {"error": {"message": "not found", "type": "not_found"}}) + return + if not self._check_auth(): + return + response_started = False + try: + length = int(self.headers.get("Content-Length", "0")) + except ValueError: + length = 0 + if length <= 0 or length > MAX_BODY_BYTES: + self._json(413, {"error": {"message": "invalid request size", "type": "invalid_request_error"}}) + return + + try: + payload = _validate_payload(json.loads(self.rfile.read(length))) + token = _access_token() + timeout = httpx.Timeout( + READ_TIMEOUT_SECONDS, + connect=30.0, + read=READ_TIMEOUT_SECONDS, + write=60.0, + pool=30.0, + ) + with httpx.Client(timeout=timeout, headers=_upstream_headers(token)) as client: + with client.stream("POST", f"{UPSTREAM}/responses", json=payload) as response: + if response.status_code >= 400: + body = response.read() + self.send_response(response.status_code) + self.send_header("Content-Type", response.headers.get("Content-Type", "application/json")) + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + return + + # HTTP/1.0 close-delimited streaming avoids buffering a + # potentially long tool-calling turn in the broker. + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-store") + self.send_header("X-Accel-Buffering", "no") + self.end_headers() + response_started = True + for chunk in response.iter_raw(): + if chunk: + self.wfile.write(chunk) + self.wfile.flush() + except ValueError as exc: + self._json(400, {"error": {"message": str(exc), "type": "invalid_request_error"}}) + except (BrokenPipeError, ConnectionResetError): + return + except Exception as exc: + if response_started: + return + self._json( + 502, + { + "error": { + "message": f"Codex broker failed: {type(exc).__name__}: {exc}", + "type": "upstream_error", + } + }, + ) + + def log_message(self, format: str, *args: Any) -> None: + """Log only method/path/status metadata, never bodies or headers.""" + print(f"codex-broker {self.address_string()} {format % args}", flush=True) + + +def main() -> None: + """Serve until Kubernetes terminates the sidecar.""" + if not TOKEN: + raise SystemExit("HERMES_IMAGE_BROKER_KEY is required") + ThreadingHTTPServer((HOST, PORT), Handler).serve_forever() + + +if __name__ == "__main__": + main() diff --git a/services/hermes/service.yaml b/services/hermes/service.yaml index bf1a078da..8ea66ba36 100644 --- a/services/hermes/service.yaml +++ b/services/hermes/service.yaml @@ -94,6 +94,23 @@ spec: --- apiVersion: v1 kind: Service +metadata: + name: hermes-codex-broker + namespace: hermes + labels: + app: hermes-agent +spec: + type: ClusterIP + selector: + app: hermes-agent + ports: + - name: http + port: 9003 + targetPort: codex-broker + protocol: TCP +--- +apiVersion: v1 +kind: Service metadata: name: hermes-ollama namespace: hermes diff --git a/testing/tests/test_hermes_chat_quality.py b/testing/tests/test_hermes_chat_quality.py index 56c06f30a..b5015941e 100644 --- a/testing/tests/test_hermes_chat_quality.py +++ b/testing/tests/test_hermes_chat_quality.py @@ -2,11 +2,15 @@ from __future__ import annotations +import base64 import importlib.util +import json import sys -from types import SimpleNamespace +import time from pathlib import Path +from types import SimpleNamespace +import pytest import yaml @@ -356,12 +360,12 @@ def test_chat_image_generation_uses_private_owner_broker(): agent_policy = next( item for item in policies if item["metadata"]["name"] == "hermes-agent-isolation" ) - image_ingress = next( + broker_ingress = next( rule for rule in agent_policy["spec"]["ingress"] - if rule["ports"] == [{"protocol": "TCP", "port": 9002}] + if {port["port"] for port in rule["ports"]} == {9002, 9003} ) - assert image_ingress["from"][0]["podSelector"]["matchLabels"] == { + assert broker_ingress["from"][0]["podSelector"]["matchLabels"] == { "app": "hermes-chat-tenant" } vault_policy = (VAULT / "scripts" / "vault_k8s_auth_configure.sh").read_text() @@ -371,6 +375,114 @@ def test_chat_image_generation_uses_private_owner_broker(): ) +def test_chat_reasoning_uses_private_owner_codex_broker(): + """Family pods receive Codex turns without mounting the owner's OAuth file.""" + configmap = _documents(HERMES / "chat-configmap.yaml")[0] + config = yaml.safe_load(configmap["data"]["config.yaml"]) + assert config["model"] == { + "provider": "atlas-codex", + "default": "gpt-5.6-terra", + "model": "gpt-5.6-terra", + } + assert config["providers"]["atlas-codex"] == { + "name": "Atlas Codex", + "api": "http://hermes-codex-broker.hermes.svc.cluster.local:9003/v1", + "key_env": "HERMES_IMAGE_BROKER_KEY", + "default_model": "gpt-5.6-terra", + "transport": "codex_responses", + } + assert config["platforms"]["api_server"]["extra"]["model_routes"] == { + "gpt-5.6-terra": { + "provider": "atlas-codex", + "model": "gpt-5.6-terra", + } + } + + agent = _documents(HERMES / "agent-deployment.yaml")[0] + containers = agent["spec"]["template"]["spec"]["containers"] + broker = next(item for item in containers if item["name"] == "codex-broker") + assert broker["ports"] == [ + {"name": "codex-broker", "containerPort": 9003, "protocol": "TCP"} + ] + assert broker["securityContext"]["readOnlyRootFilesystem"] is True + assert broker["securityContext"]["runAsNonRoot"] is True + assert broker["env"][-2:] == [ + {"name": "PYTHONPATH", "value": "/opt/hermes"}, + {"name": "HERMES_CODEX_BROKER_LISTEN_PORT", "value": "9003"}, + ] + + services = _documents(HERMES / "service.yaml") + service = next( + item for item in services if item["metadata"]["name"] == "hermes-codex-broker" + ) + assert service["spec"]["selector"] == {"app": "hermes-agent"} + assert service["spec"]["ports"] == [ + { + "name": "http", + "port": 9003, + "targetPort": "codex-broker", + "protocol": "TCP", + } + ] + + statefulset = _documents(HERMES / "chat-statefulset.yaml")[0] + hermes = next( + item + for item in statefulset["spec"]["template"]["spec"]["containers"] + if item["name"] == "hermes" + ) + assert not any( + mount["mountPath"].endswith("/.codex") + for mount in hermes["volumeMounts"] + ) + + policies = _documents(HERMES / "networkpolicy.yaml") + agent_policy = next( + item for item in policies if item["metadata"]["name"] == "hermes-agent-isolation" + ) + broker_ingress = next( + rule + for rule in agent_policy["spec"]["ingress"] + if {port["port"] for port in rule["ports"]} == {9002, 9003} + ) + assert broker_ingress["from"][0]["podSelector"]["matchLabels"] == { + "app": "hermes-chat-tenant" + } + + +def test_codex_broker_auth_and_request_contract(tmp_path: Path, monkeypatch): + """The relay is bounded, stateless, and rejects unapproved models.""" + broker_path = HERMES / "scripts" / "codex_broker.py" + monkeypatch.setitem(sys.modules, "httpx", SimpleNamespace()) + spec = importlib.util.spec_from_file_location("hermes_codex_broker", broker_path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + monkeypatch.setattr(module, "TOKEN", "relay-secret") + + assert module._authorized("Bearer relay-secret") is True + assert module._authorized("Bearer wrong") is False + payload = module._validate_payload( + {"model": "gpt-5.6-terra", "store": True, "stream": False} + ) + assert payload["store"] is False + assert payload["stream"] is True + with pytest.raises(ValueError, match="unsupported Codex model"): + module._validate_payload({"model": "unapproved-model"}) + + auth_dir = tmp_path / ".codex" + auth_dir.mkdir() + # The token payload need only prove the broker reads CODEX_HOME directly. + encoded = base64.urlsafe_b64encode( + json.dumps({"exp": time.time() + 3600}).encode() + ).decode().rstrip("=") + (auth_dir / "auth.json").write_text( + json.dumps({"tokens": {"access_token": f"header.{encoded}.signature"}}) + ) + monkeypatch.setenv("CODEX_HOME", str(auth_dir)) + assert module._access_token().startswith("header.") + + def test_image_broker_returns_bytes_and_removes_owner_cache(tmp_path: Path, monkeypatch): """The broker must not retain a family user's generated image.""" broker_path = HERMES / "scripts" / "image_broker.py" diff --git a/testing/tests/test_hermes_cli_lanes.py b/testing/tests/test_hermes_cli_lanes.py index 1ec8022b0..66635871a 100644 --- a/testing/tests/test_hermes_cli_lanes.py +++ b/testing/tests/test_hermes_cli_lanes.py @@ -795,7 +795,7 @@ def test_agent_auth_is_bstein_group_and_email_bounded(): assert '"full.path":"true"' in script -def test_agent_network_boundary_allows_only_authenticated_web_and_image_broker_surfaces(): +def test_agent_network_boundary_allows_only_authenticated_web_and_broker_surfaces(): documents = [ item for item in yaml.safe_load_all((HERMES / "networkpolicy.yaml").read_text()) @@ -826,7 +826,10 @@ def test_agent_network_boundary_allows_only_authenticated_web_and_image_broker_s } } ], - "ports": [{"protocol": "TCP", "port": 9002}], + "ports": [ + {"protocol": "TCP", "port": 9002}, + {"protocol": "TCP", "port": 9003}, + ], }, ] assert isolation["spec"]["egress"] == [{}]