Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion gateway/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -738,6 +738,7 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
# API Server
api_server_enabled = os.getenv("API_SERVER_ENABLED", "").lower() in ("true", "1", "yes")
api_server_key = os.getenv("API_SERVER_KEY", "")
api_server_cors_origins = os.getenv("API_SERVER_CORS_ORIGINS", "")
api_server_port = os.getenv("API_SERVER_PORT")
api_server_host = os.getenv("API_SERVER_HOST")
if api_server_enabled or api_server_key:
Expand All @@ -746,6 +747,10 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
config.platforms[Platform.API_SERVER].enabled = True
if api_server_key:
config.platforms[Platform.API_SERVER].extra["key"] = api_server_key
if api_server_cors_origins:
origins = [origin.strip() for origin in api_server_cors_origins.split(",") if origin.strip()]
if origins:
config.platforms[Platform.API_SERVER].extra["cors_origins"] = origins
if api_server_port:
try:
config.platforms[Platform.API_SERVER].extra["port"] = int(api_server_port)
Expand Down Expand Up @@ -786,4 +791,3 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
pass



66 changes: 62 additions & 4 deletions gateway/platforms/api_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,6 @@ def __len__(self) -> int:
# ---------------------------------------------------------------------------

_CORS_HEADERS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Authorization, Content-Type",
}
Expand All @@ -105,11 +104,23 @@ def __len__(self) -> int:
if AIOHTTP_AVAILABLE:
@web.middleware
async def cors_middleware(request, handler):
"""Add CORS headers to every response; handle OPTIONS preflight."""
"""Add CORS headers for explicitly allowed origins; handle OPTIONS preflight."""
adapter = request.app.get("api_server_adapter")
origin = request.headers.get("Origin", "")
cors_headers = None
if adapter is not None:
if not adapter._origin_allowed(origin):
return web.Response(status=403)
cors_headers = adapter._cors_headers_for_origin(origin)

if request.method == "OPTIONS":
return web.Response(status=200, headers=_CORS_HEADERS)
if cors_headers is None:
return web.Response(status=403)
return web.Response(status=200, headers=cors_headers)

response = await handler(request)
response.headers.update(_CORS_HEADERS)
if cors_headers is not None:
response.headers.update(cors_headers)
return response
else:
cors_middleware = None # type: ignore[assignment]
Expand All @@ -129,13 +140,59 @@ def __init__(self, config: PlatformConfig):
self._host: str = extra.get("host", os.getenv("API_SERVER_HOST", DEFAULT_HOST))
self._port: int = int(extra.get("port", os.getenv("API_SERVER_PORT", str(DEFAULT_PORT))))
self._api_key: str = extra.get("key", os.getenv("API_SERVER_KEY", ""))
self._cors_origins: tuple[str, ...] = self._parse_cors_origins(
extra.get("cors_origins", os.getenv("API_SERVER_CORS_ORIGINS", "")),
)
self._app: Optional["web.Application"] = None
self._runner: Optional["web.AppRunner"] = None
self._site: Optional["web.TCPSite"] = None
self._response_store = ResponseStore()
# Conversation name → latest response_id mapping
self._conversations: Dict[str, str] = {}

@staticmethod
def _parse_cors_origins(value: Any) -> tuple[str, ...]:
"""Normalize configured CORS origins into a stable tuple."""
if not value:
return ()

if isinstance(value, str):
items = value.split(",")
elif isinstance(value, (list, tuple, set)):
items = value
else:
items = [str(value)]

return tuple(str(item).strip() for item in items if str(item).strip())

def _cors_headers_for_origin(self, origin: str) -> Optional[Dict[str, str]]:
"""Return CORS headers for an allowed browser origin."""
if not origin or not self._cors_origins:
return None

if "*" in self._cors_origins:
headers = dict(_CORS_HEADERS)
headers["Access-Control-Allow-Origin"] = "*"
return headers

if origin not in self._cors_origins:
return None

headers = dict(_CORS_HEADERS)
headers["Access-Control-Allow-Origin"] = origin
headers["Vary"] = "Origin"
return headers

def _origin_allowed(self, origin: str) -> bool:
"""Allow non-browser clients and explicitly configured browser origins."""
if not origin:
return True

if not self._cors_origins:
return False

return "*" in self._cors_origins or origin in self._cors_origins

# ------------------------------------------------------------------
# Auth helper
# ------------------------------------------------------------------
Expand Down Expand Up @@ -733,6 +790,7 @@ async def connect(self) -> bool:

try:
self._app = web.Application(middlewares=[cors_middleware])
self._app["api_server_adapter"] = self
self._app.router.add_get("/health", self._handle_health)
self._app.router.add_get("/v1/models", self._handle_models)
self._app.router.add_post("/v1/chat/completions", self._handle_chat_completions)
Expand Down
112 changes: 102 additions & 10 deletions tests/gateway/test_api_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,22 +119,33 @@ def test_default_config(self):
def test_custom_config_from_extra(self):
config = PlatformConfig(
enabled=True,
extra={"host": "0.0.0.0", "port": 9999, "key": "sk-test"},
extra={
"host": "0.0.0.0",
"port": 9999,
"key": "sk-test",
"cors_origins": ["http://localhost:3000"],
},
)
adapter = APIServerAdapter(config)
assert adapter._host == "0.0.0.0"
assert adapter._port == 9999
assert adapter._api_key == "sk-test"
assert adapter._cors_origins == ("http://localhost:3000",)

def test_config_from_env(self, monkeypatch):
monkeypatch.setenv("API_SERVER_HOST", "10.0.0.1")
monkeypatch.setenv("API_SERVER_PORT", "7777")
monkeypatch.setenv("API_SERVER_KEY", "sk-env")
monkeypatch.setenv("API_SERVER_CORS_ORIGINS", "http://localhost:3000, http://127.0.0.1:3000")
config = PlatformConfig(enabled=True)
adapter = APIServerAdapter(config)
assert adapter._host == "10.0.0.1"
assert adapter._port == 7777
assert adapter._api_key == "sk-env"
assert adapter._cors_origins == (
"http://localhost:3000",
"http://127.0.0.1:3000",
)


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -190,18 +201,21 @@ def test_malformed_auth_header_returns_401(self):
# ---------------------------------------------------------------------------


def _make_adapter(api_key: str = "") -> APIServerAdapter:
def _make_adapter(api_key: str = "", cors_origins=None) -> APIServerAdapter:
"""Create an adapter with optional API key."""
extra = {}
if api_key:
extra["key"] = api_key
if cors_origins is not None:
extra["cors_origins"] = cors_origins
config = PlatformConfig(enabled=True, extra=extra)
return APIServerAdapter(config)


def _create_app(adapter: APIServerAdapter) -> web.Application:
"""Create the aiohttp app from the adapter (without starting the full server)."""
app = web.Application(middlewares=[cors_middleware])
app["api_server_adapter"] = adapter
app.router.add_get("/health", adapter._handle_health)
app.router.add_get("/v1/models", adapter._handle_models)
app.router.add_post("/v1/chat/completions", adapter._handle_chat_completions)
Expand Down Expand Up @@ -788,6 +802,19 @@ def test_env_override_port_and_host(self, monkeypatch):
assert config.platforms[Platform.API_SERVER].extra.get("port") == 9999
assert config.platforms[Platform.API_SERVER].extra.get("host") == "0.0.0.0"

def test_env_override_cors_origins(self, monkeypatch):
monkeypatch.setenv("API_SERVER_ENABLED", "true")
monkeypatch.setenv(
"API_SERVER_CORS_ORIGINS",
"http://localhost:3000, http://127.0.0.1:3000",
)
from gateway.config import load_gateway_config
config = load_gateway_config()
assert config.platforms[Platform.API_SERVER].extra.get("cors_origins") == [
"http://localhost:3000",
"http://127.0.0.1:3000",
]

def test_api_server_in_connected_platforms(self):
config = GatewayConfig()
config.platforms[Platform.API_SERVER] = PlatformConfig(enabled=True)
Expand Down Expand Up @@ -1156,26 +1183,91 @@ async def test_no_truncation_keeps_full_history(self, adapter):


class TestCORS:
def test_origin_allowed_for_non_browser_client(self, adapter):
assert adapter._origin_allowed("") is True

def test_origin_rejected_by_default(self, adapter):
assert adapter._origin_allowed("http://evil.example") is False

def test_origin_allowed_for_allowlist_match(self):
adapter = _make_adapter(cors_origins=["http://localhost:3000"])
assert adapter._origin_allowed("http://localhost:3000") is True

def test_cors_headers_for_origin_disabled_by_default(self, adapter):
assert adapter._cors_headers_for_origin("http://localhost:3000") is None

def test_cors_headers_for_origin_matches_allowlist(self):
adapter = _make_adapter(cors_origins=["http://localhost:3000"])
headers = adapter._cors_headers_for_origin("http://localhost:3000")
assert headers is not None
assert headers["Access-Control-Allow-Origin"] == "http://localhost:3000"
assert "POST" in headers["Access-Control-Allow-Methods"]

def test_cors_headers_for_origin_rejects_unknown_origin(self):
adapter = _make_adapter(cors_origins=["http://localhost:3000"])
assert adapter._cors_headers_for_origin("http://evil.example") is None

@pytest.mark.asyncio
async def test_cors_headers_on_get(self, adapter):
"""CORS headers present on normal responses."""
async def test_cors_headers_not_present_by_default(self, adapter):
"""CORS is disabled unless explicitly configured."""
app = _create_app(adapter)
async with TestClient(TestServer(app)) as cli:
resp = await cli.get("/health")
assert resp.status == 200
assert resp.headers.get("Access-Control-Allow-Origin") == "*"
assert resp.headers.get("Access-Control-Allow-Origin") is None

@pytest.mark.asyncio
async def test_browser_origin_rejected_by_default(self, adapter):
"""Browser-originated requests are rejected unless explicitly allowed."""
app = _create_app(adapter)
async with TestClient(TestServer(app)) as cli:
resp = await cli.get("/health", headers={"Origin": "http://evil.example"})
assert resp.status == 403
assert resp.headers.get("Access-Control-Allow-Origin") is None

@pytest.mark.asyncio
async def test_cors_options_preflight_rejected_by_default(self, adapter):
"""Browser preflight is rejected unless CORS is explicitly configured."""
app = _create_app(adapter)
async with TestClient(TestServer(app)) as cli:
resp = await cli.options(
"/v1/chat/completions",
headers={
"Origin": "http://evil.example",
"Access-Control-Request-Method": "POST",
},
)
assert resp.status == 403
assert resp.headers.get("Access-Control-Allow-Origin") is None

@pytest.mark.asyncio
async def test_cors_headers_present_for_allowed_origin(self):
"""Allowed origins receive explicit CORS headers."""
adapter = _make_adapter(cors_origins=["http://localhost:3000"])
app = _create_app(adapter)
async with TestClient(TestServer(app)) as cli:
resp = await cli.get("/health", headers={"Origin": "http://localhost:3000"})
assert resp.status == 200
assert resp.headers.get("Access-Control-Allow-Origin") == "http://localhost:3000"
assert "POST" in resp.headers.get("Access-Control-Allow-Methods", "")
assert "DELETE" in resp.headers.get("Access-Control-Allow-Methods", "")

@pytest.mark.asyncio
async def test_cors_options_preflight(self, adapter):
"""OPTIONS preflight request returns CORS headers."""
async def test_cors_options_preflight_allowed_for_configured_origin(self):
"""Configured origins can complete browser preflight."""
adapter = _make_adapter(cors_origins=["http://localhost:3000"])
app = _create_app(adapter)
async with TestClient(TestServer(app)) as cli:
# OPTIONS to a known path — aiohttp will route through middleware
resp = await cli.options("/health")
resp = await cli.options(
"/v1/chat/completions",
headers={
"Origin": "http://localhost:3000",
"Access-Control-Request-Method": "POST",
"Access-Control-Request-Headers": "Authorization, Content-Type",
},
)
assert resp.status == 200
assert resp.headers.get("Access-Control-Allow-Origin") == "*"
assert resp.headers.get("Access-Control-Allow-Origin") == "http://localhost:3000"
assert "Authorization" in resp.headers.get("Access-Control-Allow-Headers", "")


Expand Down
5 changes: 3 additions & 2 deletions website/docs/reference/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,9 +212,10 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe
| `WEBHOOK_PORT` | HTTP server port for receiving webhooks (default: `8644`) |
| `WEBHOOK_SECRET` | Global HMAC secret for webhook signature validation (used as fallback when routes don't specify their own) |
| `API_SERVER_ENABLED` | Enable the OpenAI-compatible API server (`true`/`false`). Runs alongside other platforms. |
| `API_SERVER_KEY` | Bearer token for API server authentication. If empty, all requests are allowed (local-only use). |
| `API_SERVER_KEY` | Bearer token for API server authentication. Strongly recommended; required for any network-accessible deployment. |
| `API_SERVER_CORS_ORIGINS` | Comma-separated browser origins allowed to call the API server directly (for example `http://localhost:3000,http://127.0.0.1:3000`). Default: disabled. |
| `API_SERVER_PORT` | Port for the API server (default: `8642`) |
| `API_SERVER_HOST` | Host/bind address for the API server (default: `127.0.0.1`). Use `0.0.0.0` for network access — set `API_SERVER_KEY` for security. |
| `API_SERVER_HOST` | Host/bind address for the API server (default: `127.0.0.1`). Use `0.0.0.0` for network access only with `API_SERVER_KEY` and a narrow `API_SERVER_CORS_ORIGINS` allowlist. |
| `MESSAGING_CWD` | Working directory for terminal commands in messaging mode (default: `~`) |
| `GATEWAY_ALLOWED_USERS` | Comma-separated user IDs allowed across all platforms |
| `GATEWAY_ALLOW_ALL_USERS` | Allow all users without allowlists (`true`/`false`, default: `false`) |
Expand Down
21 changes: 17 additions & 4 deletions website/docs/user-guide/features/api-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ Add to `~/.hermes/.env`:

```bash
API_SERVER_ENABLED=true
API_SERVER_KEY=change-me-local-dev
# Optional: only if a browser must call Hermes directly
# API_SERVER_CORS_ORIGINS=http://localhost:3000
```

### 2. Start the gateway
Expand All @@ -39,6 +42,7 @@ Point any OpenAI-compatible client at `http://localhost:8642/v1`:
```bash
# Test with curl
curl http://localhost:8642/v1/chat/completions \
-H "Authorization: Bearer change-me-local-dev" \
-H "Content-Type: application/json" \
-d '{"model": "hermes-agent", "messages": [{"role": "user", "content": "Hello!"}]}'
```
Expand Down Expand Up @@ -168,12 +172,12 @@ Bearer token auth via the `Authorization` header:
Authorization: Bearer ***
```

Configure the key via `API_SERVER_KEY` env var. If no key is set, all requests are allowed (for local-only use).
Configure the key via `API_SERVER_KEY` env var. If you need a browser to call Hermes directly, also set `API_SERVER_CORS_ORIGINS` to an explicit allowlist.

:::warning Security
The API server gives full access to hermes-agent's toolset, **including terminal commands**. If you change the bind address to `0.0.0.0` (network-accessible), **always set `API_SERVER_KEY`** — without it, anyone on your network can execute arbitrary commands on your machine.
The API server gives full access to hermes-agent's toolset, **including terminal commands**. If you change the bind address to `0.0.0.0` (network-accessible), **always set `API_SERVER_KEY`** and keep `API_SERVER_CORS_ORIGINS` narrow — without that, remote callers may be able to execute arbitrary commands on your machine.

The default bind address (`127.0.0.1`) is safe for local-only use.
The default bind address (`127.0.0.1`) is for local-only use. Browser access is disabled by default; enable it only for explicit trusted origins.
:::

## Configuration
Expand All @@ -186,6 +190,7 @@ The default bind address (`127.0.0.1`) is safe for local-only use.
| `API_SERVER_PORT` | `8642` | HTTP server port |
| `API_SERVER_HOST` | `127.0.0.1` | Bind address (localhost only by default) |
| `API_SERVER_KEY` | _(none)_ | Bearer token for auth |
| `API_SERVER_CORS_ORIGINS` | _(none)_ | Comma-separated allowed browser origins |

### config.yaml

Expand All @@ -196,7 +201,15 @@ The default bind address (`127.0.0.1`) is safe for local-only use.

## CORS

The API server includes CORS headers on all responses (`Access-Control-Allow-Origin: *`), so browser-based frontends can connect directly.
The API server does **not** enable browser CORS by default.

For direct browser access, set an explicit allowlist:

```bash
API_SERVER_CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
```

Most documented frontends such as Open WebUI connect server-to-server and do not need CORS at all.

## Compatible Frontends

Expand Down
Loading
Loading