fix: configure Codex provider authentication

This commit is contained in:
vee
2026-09-07 18:17:10 +08:00
parent 53d80ab662
commit cf9b89508c
6 changed files with 556 additions and 29 deletions
@@ -30,6 +30,10 @@ CALLBACK_TIMEOUT_ENV: Final = "COGNITIVE_AGENT_CODEX_VEHICLE_CALLBACK_TIMEOUT_SE
PYTHON_IO_ENCODING_ENV: Final = "PYTHONIOENCODING"
PYTHON_UTF8_ENV: Final = "PYTHONUTF8"
_OPENAI_API_KEY_ENV: Final = "OPENAI_API_KEY"
_OPENAI_DEFAULT_BASE_URL: Final = "https://api.openai.com/v1"
_OPENAI_COMPATIBLE_PROVIDER_ID: Final = "n7web_openai_compatible"
_REQUEST_TOKEN_RE = re.compile(r"[A-Za-z0-9._~-]{8,256}\Z")
_PROCESS_ENVIRONMENT_ALLOWLIST: Final = frozenset(
{
@@ -198,11 +202,25 @@ def _allowlisted_process_environment(
source = {str(key): str(value) for key, value in os.environ.items()}
source.update({str(key): str(value) for key, value in (overrides or {}).items()})
return {
key: value
for key, value in source.items()
if key.upper() in _PROCESS_ENVIRONMENT_ALLOWLIST or key.upper().startswith("LC_")
}
environment: dict[str, str] = {}
for key, value in source.items():
normalized = key.upper()
if normalized in _PROCESS_ENVIRONMENT_ALLOWLIST or normalized.startswith("LC_"):
# Environment names are case-sensitive on Linux but not Windows.
# Canonicalizing prevents a differently-cased explicit override
# from coexisting with, and losing to, an inherited credential.
environment[normalized] = value
return environment
def _environment_value(environment: Mapping[str, str], name: str) -> str | None:
"""Read an environment value without relying on platform key casing."""
expected = name.upper()
for key, value in reversed(tuple(environment.items())):
if key.upper() == expected:
return value
return None
def _validate_loopback_url(value: str) -> str:
@@ -233,6 +251,54 @@ def _validate_loopback_url(value: str) -> str:
return url
def _validate_model_base_url(value: str) -> str:
"""Validate a model endpoint before placing it in the Codex command line.
Provider base URLs are configuration rather than credentials. Still, user
info, query strings and fragments are rejected so a bearer token can never
be smuggled into process arguments. Clear-text HTTP is limited to loopback
endpoints for local integration tests and development proxies.
"""
url = value.strip().rstrip("/")
if not url or len(url) > 2048:
raise ValueError("OPENAI_BASE_URL must be a non-empty HTTP(S) URL")
try:
parsed = urlsplit(url)
port = parsed.port
except ValueError as exc:
raise ValueError("OPENAI_BASE_URL contains an invalid port") from exc
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
raise ValueError("OPENAI_BASE_URL must be an HTTP(S) URL")
if parsed.username or parsed.password or parsed.query or parsed.fragment:
raise ValueError("OPENAI_BASE_URL must not contain credentials, a query, or a fragment")
if port is not None and not 1 <= port <= 65535:
raise ValueError("OPENAI_BASE_URL contains an invalid port")
if parsed.scheme == "http":
hostname = parsed.hostname.lower()
if hostname != "localhost":
try:
address = ipaddress.ip_address(hostname)
except ValueError as exc:
raise ValueError(
"OPENAI_BASE_URL must use HTTPS unless it targets loopback"
) from exc
if not address.is_loopback:
raise ValueError("OPENAI_BASE_URL must use HTTPS unless it targets loopback")
return url
def _is_default_openai_base_url(value: str) -> bool:
parsed = urlsplit(value)
default = urlsplit(_OPENAI_DEFAULT_BASE_URL)
return (
parsed.scheme == default.scheme
and (parsed.hostname or "").lower() == (default.hostname or "").lower()
and parsed.port is None
and parsed.path.rstrip("/") in {"", "/v1"}
)
def _validate_secret(value: str, field_name: str, *, minimum: int = 16) -> str:
secret = value.strip()
if len(secret) < minimum or len(secret) > 1024:
@@ -297,17 +363,47 @@ class CodexAppServerClient:
if not self._mcp_server_path.is_file():
raise ValueError(f"vehicle MCP server does not exist: {self._mcp_server_path}")
self._environment = {str(key): str(value) for key, value in (environment or {}).items()}
configured_base_url = _environment_value(self._environment, "OPENAI_BASE_URL")
if configured_base_url is None:
configured_base_url = os.getenv("OPENAI_BASE_URL")
self._openai_base_url = (
_validate_model_base_url(configured_base_url)
if configured_base_url and configured_base_url.strip()
else None
)
self._uses_openai_compatible_provider = bool(
self._openai_base_url and not _is_default_openai_base_url(self._openai_base_url)
)
environment_model = _environment_value(
self._environment, "CODEX_ASSISTANT_MODEL"
) or _environment_value(self._environment, "COGNITIVE_AGENT_CODEX_MODEL")
self._model = (
model
or environment_model
or os.getenv("CODEX_ASSISTANT_MODEL")
or os.getenv("COGNITIVE_AGENT_CODEX_MODEL")
or "gpt-5.6-luna"
).strip() or None
self._model_provider = (
os.getenv("CODEX_ASSISTANT_MODEL_PROVIDER")
environment_model_provider = _environment_value(
self._environment, "CODEX_ASSISTANT_MODEL_PROVIDER"
) or _environment_value(self._environment, "COGNITIVE_AGENT_CODEX_MODEL_PROVIDER")
configured_model_provider = (
environment_model_provider
or os.getenv("CODEX_ASSISTANT_MODEL_PROVIDER")
or os.getenv("COGNITIVE_AGENT_CODEX_MODEL_PROVIDER")
or ""
).strip() or None
# Codex's built-in OpenAI provider intentionally ignores
# OPENAI_BASE_URL. Select an explicit provider whenever N7Web is
# configured for an OpenAI-compatible proxy so the model request cannot
# accidentally be sent to api.openai.com with the proxy credential.
self._model_provider = (
_OPENAI_COMPATIBLE_PROVIDER_ID
if self._uses_openai_compatible_provider
else configured_model_provider
)
self._reasoning_effort = (
reasoning_effort
or os.getenv("CODEX_ASSISTANT_REASONING_EFFORT")
@@ -342,8 +438,6 @@ class CodexAppServerClient:
or os.getenv("COGNITIVE_AGENT_CODEX_VEHICLE_TOOL_TIMEOUT_SECONDS"),
45.0,
)
self._environment = {str(key): str(value) for key, value in (environment or {}).items()}
self._lifecycle_lock = threading.Lock()
self._lock = threading.RLock()
self._write_lock = threading.Lock()
@@ -412,6 +506,9 @@ class CodexAppServerClient:
command = self._build_command()
child_environment = _allowlisted_process_environment(self._environment)
raw_api_key = _environment_value(child_environment, _OPENAI_API_KEY_ENV)
if raw_api_key is not None:
child_environment[_OPENAI_API_KEY_ENV] = raw_api_key.strip()
child_environment[CALLBACK_URL_ENV] = callback_url
child_environment[CALLBACK_TOKEN_ENV] = callback_token
child_environment[CALLBACK_TIMEOUT_ENV] = str(self._vehicle_tool_timeout)
@@ -478,6 +575,7 @@ class CodexAppServerClient:
allow_starting=True,
)
self._notify("initialized", {})
self._ensure_model_authentication(child_environment)
config_result = self._rpc(
"config/read",
{"cwd": str(self._cwd), "includeLayers": False},
@@ -521,6 +619,81 @@ class CodexAppServerClient:
self._failure = exc
raise
def _ensure_model_authentication(self, child_environment: Mapping[str, str]) -> None:
"""Fail startup unless the effective model provider has usable auth.
App Server initialization and even ``thread/start`` succeed without an
OpenAI account, which previously made ``/api/health`` report a false
ready state. A custom OpenAI-compatible provider consumes its key
directly from the named environment variable. The built-in OpenAI
provider instead requires an App Server account and therefore receives
an explicit API-key login when no cached account exists.
"""
raw_api_key = _environment_value(child_environment, _OPENAI_API_KEY_ENV)
if self._uses_openai_compatible_provider:
try:
_validate_secret(raw_api_key or "", _OPENAI_API_KEY_ENV, minimum=8)
except ValueError as exc:
raise CodexAppServerUnavailable(
"Codex custom model provider authentication is not configured"
) from exc
return
account, requires_openai_auth = self._read_account_state()
if not requires_openai_auth:
return
if account is not None:
return
try:
api_key = _validate_secret(raw_api_key or "", _OPENAI_API_KEY_ENV, minimum=8)
except ValueError as exc:
raise CodexAppServerUnavailable(
"Codex App Server authentication is not configured"
) from exc
try:
login_result = self._rpc(
"account/login/start",
{"type": "apiKey", "apiKey": api_key},
timeout=self._startup_timeout,
allow_starting=True,
)
except CodexAppServerError:
# Do not propagate an upstream diagnostic that could echo request
# data. In particular, the API key must never enter status output.
raise CodexAppServerUnavailable(
"Codex App Server API-key authentication failed"
) from None
if not isinstance(login_result, Mapping) or login_result.get("type") != "apiKey":
raise CodexAppServerProtocolError(
"account/login/start returned an invalid authentication result"
)
account, _ = self._read_account_state()
if account is None:
raise CodexAppServerUnavailable(
"Codex App Server API-key authentication was not established"
)
def _read_account_state(self) -> tuple[Mapping[str, Any] | None, bool]:
result = self._rpc(
"account/read",
{"refreshToken": False},
timeout=self._startup_timeout,
allow_starting=True,
)
if not isinstance(result, Mapping):
raise CodexAppServerProtocolError("account/read returned an invalid result")
requires_openai_auth = result.get("requiresOpenaiAuth")
account = result.get("account")
if not isinstance(requires_openai_auth, bool):
raise CodexAppServerProtocolError("account/read returned no authentication requirement")
if account is not None and not isinstance(account, Mapping):
raise CodexAppServerProtocolError("account/read returned an invalid account")
return account, requires_openai_auth
def close(self) -> None:
"""Stop the child process and fail outstanding work."""
@@ -577,12 +750,20 @@ class CodexAppServerClient:
"model_verbosity": "low",
"model_reasoning_summary": "none",
}
if self._model_provider and self._model_provider not in {"openai", "chatgpt"}:
provider_prefix = f"model_providers.{self._model_provider}"
if self._disable_websockets:
config_overrides[f"{provider_prefix}.supports_websockets"] = False
config_overrides[f"{provider_prefix}.stream_max_retries"] = self._stream_max_retries
config_overrides[f"{provider_prefix}.request_max_retries"] = self._request_max_retries
if self._uses_openai_compatible_provider:
assert self._openai_base_url is not None
config_overrides["model_providers"] = {
_OPENAI_COMPATIBLE_PROVIDER_ID: {
"name": "N7Web OpenAI-compatible",
"base_url": self._openai_base_url,
"env_key": _OPENAI_API_KEY_ENV,
"wire_api": "responses",
"requires_openai_auth": False,
"supports_websockets": not self._disable_websockets,
"stream_max_retries": self._stream_max_retries,
"request_max_retries": self._request_max_retries,
}
}
params["config"] = config_overrides
method = "thread/start"
+16 -5
View File
@@ -1834,11 +1834,16 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
def _handle_codex_vehicle_control(self) -> None:
"""Run an allowlisted MCP tool over the authenticated loopback bridge."""
try:
content_length = int(self.headers.get("Content-Length", "0"))
except ValueError:
content_length = 0
if not self._is_unproxied_loopback_request():
self._json(
{"error": "local_access_required", "message": "仅允许本机 Codex 工具调用。"},
HTTPStatus.FORBIDDEN,
)
self._drain_rejected_request_body(content_length, max_bytes=32 * 1024)
return
if not self.assistant.authenticate_internal_request(
self.headers.get("X-N7-Internal-Token", "")
@@ -1847,6 +1852,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
{"error": "invalid_internal_token", "message": "Codex 工具认证失败。"},
HTTPStatus.FORBIDDEN,
)
self._drain_rejected_request_body(content_length, max_bytes=32 * 1024)
return
try:
payload = self._read_json_body(max_bytes=32 * 1024)
@@ -2846,17 +2852,22 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
if slot_acquired:
self.speech_preview_http_slots.release()
def _drain_rejected_request_body(self, content_length: int) -> None:
"""Gracefully finish an early-rejected POST without doing speech work.
def _drain_rejected_request_body(
self,
content_length: int,
*,
max_bytes: int = 2 * 1024 * 1024,
) -> None:
"""Gracefully finish an early-rejected POST without processing its payload.
Closing a Windows socket while its peer's request body is still unread can
replace an already-written HTTP error with TCP RST. Flush the rejection
first, then briefly discard a valid-sized body so clients reliably receive
the 4xx response. The preview/transcriber concurrency gates still run
before any audio is read or decoded.
the 4xx response. Callers choose a strict size ceiling, and authorization or
concurrency gates still run before any payload is read or decoded.
"""
if content_length <= 0 or content_length > 2 * 1024 * 1024:
if content_length <= 0 or content_length > max_bytes:
return
self.close_connection = True
try: