fix: configure Codex provider authentication
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user