hermes: broker isolated chat through Codex
All checks were successful
Tests / Declarative: Post Actions passed: 237
All checks were successful
Tests / Declarative: Post Actions passed: 237
This commit is contained in:
parent
8c7a164e75
commit
ba5982bc80
@ -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:
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
224
services/hermes/scripts/codex_broker.py
Normal file
224
services/hermes/scripts/codex_broker.py
Normal 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()
|
||||||
@ -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
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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"] == [{}]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user