hermes: broker isolated chat through Codex
All checks were successful
Tests / Declarative: Post Actions passed: 237

This commit is contained in:
jenkins 2026-08-11 03:15:34 -03:00
parent 8c7a164e75
commit ba5982bc80
8 changed files with 427 additions and 7 deletions

View File

@ -736,6 +736,52 @@ spec:
resources: resources:
requests: {cpu: 50m, memory: 128Mi} requests: {cpu: 50m, memory: 128Mi}
limits: {cpu: "1", memory: 1Gi} 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: volumes:
- name: home - name: home
persistentVolumeClaim: persistentVolumeClaim:

View File

@ -9,9 +9,16 @@ metadata:
data: data:
config.yaml: | config.yaml: |
model: model:
provider: openai-codex provider: atlas-codex
default: gpt-5.6-terra default: gpt-5.6-terra
model: 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: fallback_providers:
- provider: anthropic - provider: anthropic
model: claude-sonnet-5 model: claude-sonnet-5
@ -53,6 +60,14 @@ data:
platform_toolsets: platform_toolsets:
cli: [browser, clarify, delegation, file, image_gen, memory, python_sandbox, session_search, skills, todo, vision, web] 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] 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: dashboard:
public_url: https://chat.hermes.bstein.dev public_url: https://chat.hermes.bstein.dev
display: display:

View File

@ -52,6 +52,7 @@ configMapGenerator:
- cli_lane_runner.py=scripts/cli_lane_runner.py - cli_lane_runner.py=scripts/cli_lane_runner.py
- codex=scripts/codex - codex=scripts/codex
- configure_agent_clients.py=scripts/configure_agent_clients.py - configure_agent_clients.py=scripts/configure_agent_clients.py
- codex_broker.py=scripts/codex_broker.py
- gitea_askpass.sh=scripts/gitea_askpass.sh - gitea_askpass.sh=scripts/gitea_askpass.sh
- hermes_coordinator.py=scripts/hermes_coordinator.py - hermes_coordinator.py=scripts/hermes_coordinator.py
- hermes_model_routing.py=scripts/hermes_model_routing.py - hermes_model_routing.py=scripts/hermes_model_routing.py

View File

@ -82,6 +82,7 @@ spec:
app: hermes-chat-tenant app: hermes-chat-tenant
ports: ports:
- {protocol: TCP, port: 9002} - {protocol: TCP, port: 9002}
- {protocol: TCP, port: 9003}
# agent.hermes.bstein.dev is an owner-only engineering workstation. The # agent.hermes.bstein.dev is an owner-only engineering workstation. The
# browser boundary remains OAuth-protected, while its workers need to reach # browser boundary remains OAuth-protected, while its workers need to reach
# every cluster namespace, Atlas LAN service, and hosted provider endpoint. # every cluster namespace, Atlas LAN service, and hosted provider endpoint.
@ -236,6 +237,7 @@ spec:
app: hermes-agent app: hermes-agent
ports: ports:
- {protocol: TCP, port: 9002} - {protocol: TCP, port: 9002}
- {protocol: TCP, port: 9003}
- to: - to:
- podSelector: - podSelector:
matchLabels: matchLabels:

View File

@ -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()

View File

@ -94,6 +94,23 @@ spec:
--- ---
apiVersion: v1 apiVersion: v1
kind: Service 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: metadata:
name: hermes-ollama name: hermes-ollama
namespace: hermes namespace: hermes

View File

@ -2,11 +2,15 @@
from __future__ import annotations from __future__ import annotations
import base64
import importlib.util import importlib.util
import json
import sys import sys
from types import SimpleNamespace import time
from pathlib import Path from pathlib import Path
from types import SimpleNamespace
import pytest
import yaml import yaml
@ -356,12 +360,12 @@ def test_chat_image_generation_uses_private_owner_broker():
agent_policy = next( agent_policy = next(
item for item in policies if item["metadata"]["name"] == "hermes-agent-isolation" item for item in policies if item["metadata"]["name"] == "hermes-agent-isolation"
) )
image_ingress = next( broker_ingress = next(
rule rule
for rule in agent_policy["spec"]["ingress"] 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" "app": "hermes-chat-tenant"
} }
vault_policy = (VAULT / "scripts" / "vault_k8s_auth_configure.sh").read_text() 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): 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.""" """The broker must not retain a family user's generated image."""
broker_path = HERMES / "scripts" / "image_broker.py" broker_path = HERMES / "scripts" / "image_broker.py"

View File

@ -795,7 +795,7 @@ def test_agent_auth_is_bstein_group_and_email_bounded():
assert '"full.path":"true"' in script 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 = [ documents = [
item item
for item in yaml.safe_load_all((HERMES / "networkpolicy.yaml").read_text()) 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"] == [{}] assert isolation["spec"]["egress"] == [{}]