Skip to content

Commit 735e9be

Browse files
[codex] Support regex CORS origins (#3664)
Co-authored-by: Graham Neubig <398875+neubig@users.noreply.github.com> Co-authored-by: openhands <openhands@all-hands.dev>
1 parent 1dcd65f commit 735e9be

6 files changed

Lines changed: 190 additions & 12 deletions

File tree

openhands-agent-server/openhands/agent_server/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ The server can be configured using environment variables or a JSON configuration
5454
| `OPENHANDS_AGENT_SERVER_CONFIG_PATH` | Path to JSON configuration file | `workspace/openhands_agent_server_config.json` |
5555
| `SESSION_API_KEY` | API key for authentication (optional) | None |
5656
| `OH_SECRET_KEY` | Secret key for encrypting sensitive data (LLM API keys, secrets) in stored conversations. **Required for persistence across restarts.** | None |
57+
| `OH_ALLOW_CORS_ORIGIN_REGEX` | Regular expression for additional allowed CORS origins. Use `https?://.+` to allow any HTTP(S) origin while echoing the concrete origin. | None |
5758

5859
### Configuration File
5960

@@ -63,6 +64,7 @@ Create a JSON configuration file (default: `workspace/openhands_agent_server_con
6364
{
6465
"session_api_key": "your-secret-api-key",
6566
"allow_cors_origins": ["https://your-frontend.com"],
67+
"allow_cors_origin_regex": null,
6668
"conversations_path": "workspace/conversations",
6769
"webhooks": [
6870
{
@@ -83,6 +85,7 @@ Create a JSON configuration file (default: `workspace/openhands_agent_server_con
8385

8486
- **`session_api_key`**: Optional API key for securing the server. If set, all requests must include this key in the `Authorization` header as `Bearer <key>`
8587
- **`allow_cors_origins`**: List of allowed CORS origins (localhost is always allowed)
88+
- **`allow_cors_origin_regex`**: Regular expression for additional allowed CORS origins. Use `https?://.+` to allow any HTTP(S) origin while keeping credential-compatible origin echoing.
8689
- **`webhooks`**: Array of webhook configurations for event notifications
8790

8891
**Note**: Directory configuration (`working_dir`) will be handled at the conversation level rather than globally. These directories are specified when starting a conversation through the API.

openhands-agent-server/openhands/agent_server/api.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -556,7 +556,11 @@ def create_app(config: Config | None = None) -> FastAPI:
556556

557557
_add_api_routes(app, config)
558558
_setup_static_files(app, config)
559-
app.add_middleware(CORSDispatcher, allow_origins=config.allow_cors_origins)
559+
app.add_middleware(
560+
CORSDispatcher,
561+
allow_origins=config.allow_cors_origins,
562+
allow_origin_regex=config.allow_cors_origin_regex,
563+
)
560564
_add_exception_handlers(app)
561565

562566
return app

openhands-agent-server/openhands/agent_server/config.py

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,27 @@
1+
import json
12
import logging
23
import os
34
from pathlib import Path
4-
from typing import ClassVar
5+
from typing import Any, ClassVar
56

67
from pydantic import BaseModel, ConfigDict, Field, SecretStr
78

8-
from openhands.agent_server.env_parser import from_env
9+
from openhands.agent_server.env_parser import (
10+
MISSING,
11+
_get_default_parsers,
12+
from_env, # noqa: F401 - compatibility re-export
13+
get_env_parser,
14+
merge,
15+
)
916
from openhands.sdk.utils.cipher import Cipher
1017

1118

1219
# Environment variable constants
1320
V0_SESSION_API_KEY_ENV = "SESSION_API_KEY"
1421
V1_SESSION_API_KEY_ENV = "OH_SESSION_API_KEYS_0"
1522
ENVIRONMENT_VARIABLE_PREFIX = "OH"
23+
CONFIG_PATH_ENV = "OPENHANDS_AGENT_SERVER_CONFIG_PATH"
24+
DEFAULT_CONFIG_PATH = Path("workspace/openhands_agent_server_config.json")
1625
_logger = logging.getLogger(__name__)
1726

1827

@@ -126,6 +135,15 @@ class Config(BaseModel):
126135
"``middleware.py``."
127136
),
128137
)
138+
allow_cors_origin_regex: str | None = Field(
139+
default=None,
140+
description=(
141+
"Regular expression matching additional CORS origins permitted by "
142+
"this server. Localhost / 127.0.0.1 and ``DOCKER_HOST_ADDR`` are "
143+
"always allowed. Does not apply to the workspace cookie routes, "
144+
"which accept any origin — see ``middleware.py``."
145+
),
146+
)
129147
conversations_path: Path = Field(
130148
default=Path("workspace/conversations"),
131149
description=(
@@ -239,11 +257,43 @@ def cipher(self) -> Cipher | None:
239257
_default_config: Config | None = None
240258

241259

260+
def _read_config_file(path: Path) -> dict[str, Any]:
261+
if not path.exists():
262+
return {}
263+
data = json.loads(path.read_text())
264+
if not isinstance(data, dict):
265+
raise ValueError(f"Config file must contain a JSON object: {path}")
266+
return data
267+
268+
269+
def load_config(config_path: Path | None = None) -> Config:
270+
"""Load agent-server config from JSON file and environment variables.
271+
272+
Values from ``OH_*`` environment variables override values from the JSON
273+
config file so deployment-specific environment overrides keep working.
274+
"""
275+
resolved_path = config_path
276+
if resolved_path is None:
277+
resolved_path = Path(os.getenv(CONFIG_PATH_ENV, DEFAULT_CONFIG_PATH))
278+
279+
file_data = _read_config_file(resolved_path)
280+
parser = get_env_parser(Config, _get_default_parsers())
281+
env_data = parser.from_env(ENVIRONMENT_VARIABLE_PREFIX)
282+
283+
if env_data is MISSING:
284+
data = file_data
285+
else:
286+
data = merge(file_data, env_data)
287+
288+
if not data:
289+
return Config()
290+
return Config.model_validate(data)
291+
292+
242293
def get_default_config() -> Config:
243294
"""Get the default local server config shared across the server"""
244295
global _default_config
245296
if _default_config is None:
246-
# Get the config from the environment variables
247-
_default_config = from_env(Config, ENVIRONMENT_VARIABLE_PREFIX)
297+
_default_config = load_config()
248298
assert _default_config is not None
249299
return _default_config

openhands-agent-server/openhands/agent_server/middleware.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@
88
the request Origin on every response. These are the only routes that
99
authenticate via an ambient (cookie) credential.
1010
* Everything else — ``LocalhostCORSMiddleware``, which honors the
11-
operator's ``allow_cors_origins`` and always allows localhost and
12-
``DOCKER_HOST_ADDR`` (matches OpenHands/OpenHands#4624 intent).
11+
operator's ``allow_cors_origins`` / ``allow_cors_origin_regex`` and always
12+
allows localhost and ``DOCKER_HOST_ADDR`` (matches OpenHands/OpenHands#4624
13+
intent).
1314
"""
1415

1516
import os
@@ -33,10 +34,16 @@ def _is_workspace_cookie_path(path: str) -> bool:
3334
class LocalhostCORSMiddleware(CORSMiddleware):
3435
"""``CORSMiddleware`` that always allows localhost and ``DOCKER_HOST_ADDR``."""
3536

36-
def __init__(self, app: ASGIApp, allow_origins: list[str]) -> None:
37+
def __init__(
38+
self,
39+
app: ASGIApp,
40+
allow_origins: list[str],
41+
allow_origin_regex: str | None = None,
42+
) -> None:
3743
super().__init__(
3844
app,
3945
allow_origins=allow_origins,
46+
allow_origin_regex=allow_origin_regex,
4047
allow_credentials=True,
4148
allow_methods=["*"],
4249
allow_headers=["*"],
@@ -69,9 +76,17 @@ class CORSDispatcher:
6976
partition key and are not legitimate clients.
7077
"""
7178

72-
def __init__(self, app: ASGIApp, *, allow_origins: list[str]) -> None:
79+
def __init__(
80+
self,
81+
app: ASGIApp,
82+
*,
83+
allow_origins: list[str],
84+
allow_origin_regex: str | None = None,
85+
) -> None:
7386
self._default_cors = LocalhostCORSMiddleware(
74-
app, allow_origins=list(allow_origins)
87+
app,
88+
allow_origins=list(allow_origins),
89+
allow_origin_regex=allow_origin_regex,
7590
)
7691
self._workspace_cors = CORSMiddleware(
7792
app,

tests/agent_server/test_cors.py

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from fastapi.testclient import TestClient
99

1010
from openhands.agent_server.api import create_app
11-
from openhands.agent_server.config import Config
11+
from openhands.agent_server.config import Config, load_config
1212
from openhands.agent_server.conversation_service import ConversationService
1313
from openhands.agent_server.dependencies import (
1414
WORKSPACE_SESSION_COOKIE_NAME,
@@ -194,6 +194,64 @@ def test_non_workspace_routes_reject_unlisted_origin(tmp_path):
194194
assert "access-control-allow-origin" not in resp.headers
195195

196196

197+
def test_non_workspace_regex_echoes_http_origin_on_actual_response(tmp_path):
198+
"""Regex origin matches must remain credential-compatible.
199+
200+
Starlette's regex path echoes the concrete origin instead of emitting a
201+
literal wildcard, so browsers accept the response together with
202+
``Access-Control-Allow-Credentials: true``.
203+
"""
204+
client = _build_client(
205+
tmp_path,
206+
conversation_id=uuid4(),
207+
config=Config(
208+
session_api_keys=[SESSION_KEY],
209+
allow_cors_origin_regex=r"https?://.+",
210+
),
211+
)
212+
213+
resp = client.get("/server_info", headers={"Origin": OTHER_ORIGIN})
214+
215+
assert resp.status_code == 200
216+
assert resp.headers["access-control-allow-origin"] == OTHER_ORIGIN
217+
assert resp.headers["access-control-allow-credentials"] == "true"
218+
assert "Origin" in resp.headers.get("vary", "")
219+
220+
221+
def test_json_config_regex_echoes_http_origin_on_actual_response(tmp_path):
222+
config_path = tmp_path / "config.json"
223+
config_path.write_text(
224+
'{"allow_cors_origin_regex": "https://.*\\\\.example\\\\.com"}'
225+
)
226+
client = _build_client(
227+
tmp_path,
228+
conversation_id=uuid4(),
229+
config=load_config(config_path),
230+
)
231+
232+
resp = client.get("/server_info", headers={"Origin": "https://app.example.com"})
233+
234+
assert resp.status_code == 200
235+
assert resp.headers["access-control-allow-origin"] == "https://app.example.com"
236+
assert resp.headers["access-control-allow-credentials"] == "true"
237+
assert "Origin" in resp.headers.get("vary", "")
238+
239+
240+
def test_non_workspace_regex_rejects_null_origin(tmp_path):
241+
client = _build_client(
242+
tmp_path,
243+
conversation_id=uuid4(),
244+
config=Config(
245+
session_api_keys=[SESSION_KEY],
246+
allow_cors_origin_regex=r"https?://.+",
247+
),
248+
)
249+
250+
resp = _preflight(client, "/api/conversations", origin="null")
251+
252+
assert "access-control-allow-origin" not in resp.headers
253+
254+
197255
def test_workspace_wildcard_does_not_bleed_into_other_api(tmp_path):
198256
client = _build_client(
199257
tmp_path, conversation_id=uuid4(), config=Config(session_api_keys=[SESSION_KEY])

tests/agent_server/test_env_parser.py

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
import pytest
2222
from pydantic import BaseModel, Field
2323

24-
from openhands.agent_server.config import Config
24+
from openhands.agent_server.config import Config, load_config
2525
from openhands.agent_server.env_parser import (
2626
MISSING,
2727
BoolEnvParser,
@@ -441,6 +441,7 @@ def test_config_class_parsing(clean_env):
441441
os.environ["OH_SESSION_API_KEYS_0"] = "key1"
442442
os.environ["OH_SESSION_API_KEYS_1"] = "key2"
443443
os.environ["OH_ALLOW_CORS_ORIGINS_0"] = "http://localhost:3000"
444+
os.environ["OH_ALLOW_CORS_ORIGIN_REGEX"] = r"https://.*\.example\.com"
444445
os.environ["OH_CONVERSATIONS_PATH"] = "/custom/conversations"
445446
os.environ["OH_WORKSPACE_PATH"] = "/custom/workspace"
446447
os.environ["OH_ENABLE_VSCODE"] = "false"
@@ -449,11 +450,58 @@ def test_config_class_parsing(clean_env):
449450

450451
assert config.session_api_keys == ["key1", "key2"]
451452
assert config.allow_cors_origins == ["http://localhost:3000"]
453+
assert config.allow_cors_origin_regex == r"https://.*\.example\.com"
452454
assert config.conversations_path == Path("/custom/conversations")
453455
assert config.workspace_path == Path("/custom/workspace")
454456
assert config.enable_vscode is False
455457

456458

459+
def test_config_file_parsing(tmp_path, clean_env):
460+
"""JSON config files should populate the same Config fields as env vars."""
461+
config_path = tmp_path / "config.json"
462+
config_path.write_text(
463+
json.dumps(
464+
{
465+
"session_api_keys": ["file-key"],
466+
"allow_cors_origins": ["https://frontend.example.com"],
467+
"allow_cors_origin_regex": r"https://.*\.example\.com",
468+
"conversations_path": str(tmp_path / "conversations"),
469+
"workspace_path": str(tmp_path / "workspace"),
470+
"enable_vscode": False,
471+
}
472+
)
473+
)
474+
475+
config = load_config(config_path)
476+
477+
assert config.session_api_keys == ["file-key"]
478+
assert config.allow_cors_origins == ["https://frontend.example.com"]
479+
assert config.allow_cors_origin_regex == r"https://.*\.example\.com"
480+
assert config.conversations_path == tmp_path / "conversations"
481+
assert config.workspace_path == tmp_path / "workspace"
482+
assert config.enable_vscode is False
483+
484+
485+
def test_config_file_parsing_with_env_override(tmp_path, clean_env):
486+
"""Environment variables should override JSON config file values."""
487+
config_path = tmp_path / "config.json"
488+
config_path.write_text(
489+
json.dumps(
490+
{
491+
"allow_cors_origins": ["https://file.example.com"],
492+
"allow_cors_origin_regex": r"https://file\\..+",
493+
}
494+
)
495+
)
496+
os.environ["OH_ALLOW_CORS_ORIGINS_0"] = "https://env.example.com"
497+
os.environ["OH_ALLOW_CORS_ORIGIN_REGEX"] = r"https://env\\..+"
498+
499+
config = load_config(config_path)
500+
501+
assert config.allow_cors_origins == ["https://env.example.com"]
502+
assert config.allow_cors_origin_regex == r"https://env\\..+"
503+
504+
457505
def test_config_webhook_specs_parsing(clean_env):
458506
"""Test parsing webhook specs in Config class."""
459507
# Test with JSON webhook specs

0 commit comments

Comments
 (0)