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:
|
||||
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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"] == [{}]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user