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
+2 -2
View File
@@ -125,9 +125,9 @@ codex login status
make dev
```
也可以让 Codex 使用进程环境中的 `OPENAI_API_KEY`。助手默认选择低推理强度的 `gpt-5.6-luna`,可用 `CODEX_ASSISTANT_MODEL` 覆盖;`CODEX_ASSISTANT_REASONING_EFFORT` 默认为 `low`,`CODEX_ASSISTANT_TURN_TIMEOUT_SECONDS` 默认为 300 秒,`CODEX_ASSISTANT_WORKSPACE` 用来指定隔离的只读工作目录。为避免兼容服务的 WebSocket 断流造成长时间重试,非 `openai`/`chatgpt` 的自定义 provider 默认关闭 WebSocket,并把模型流重试限制为一次;可通过 `CODEX_ASSISTANT_DISABLE_WEBSOCKETS` 和 `CODEX_ASSISTANT_STREAM_MAX_RETRIES` 调整。
也可以让 Codex 使用进程环境中的 `OPENAI_API_KEY`。未配置自定义地址时,N7Web 会在 App Server 没有缓存账户的情况下通过 API-key 登录;配置非官方 `OPENAI_BASE_URL` 时,会自动为线程注入完整的 OpenAI-compatible provider,使 Codex 确实请求该地址,而不是回落到 `api.openai.com`。助手默认选择低推理强度的 `gpt-5.6-luna`,可用 `CODEX_ASSISTANT_MODEL` 覆盖;`CODEX_ASSISTANT_REASONING_EFFORT` 默认为 `low`,`CODEX_ASSISTANT_TURN_TIMEOUT_SECONDS` 默认为 300 秒,`CODEX_ASSISTANT_WORKSPACE` 用来指定隔离的只读工作目录。为避免兼容服务的 WebSocket 断流造成长时间重试,自定义 provider 默认关闭 WebSocket,并把模型流重试限制为一次;可通过 `CODEX_ASSISTANT_DISABLE_WEBSOCKETS` 和 `CODEX_ASSISTANT_STREAM_MAX_RETRIES` 调整。
启动采用失败关闭语义:CLI 缺失、App Server 握手失败或有效 MCP 配置未能收敛并验证为仅启用 `n7_vehicle` 时,`cognitive_agent.server` 会直接启动失败,不会继续提供一个看似可用的降级网页。进程启动后若 App Server 退出,`/api/health` 返回 `503`,`/api/assistant/status` 显示不可用;模型认证、权限、网络或生成失败则由当前问答流返回 `assistant_model_failed`。这些情况都不会静默回退到旧的正则问答路由;本地能力快答和冷热快速控制仍只按各自严格的确定性条件工作。
启动采用失败关闭语义:CLI 缺失、App Server 握手失败、模型认证配置缺失/登录失败,或有效 MCP 配置未能收敛并验证为仅启用 `n7_vehicle` 时,`cognitive_agent.server` 会直接启动失败,不会继续提供一个看似可用的降级网页。进程启动后若 App Server 退出,`/api/health` 返回 `503`,`/api/assistant/status` 显示不可用;凭据被服务端撤销、模型权限、网络或生成失败等只有真实模型请求才能发现的问题,则由当前问答流返回 `assistant_model_failed`。这些情况都不会静默回退到旧的正则问答路由;本地能力快答和冷热快速控制仍只按各自严格的确定性条件工作。
App Server 的协议与审批字段以 [Codex App Server 官方文档](https://learn.chatgpt.com/docs/app-server) 和 [Codex 配置参考](https://learn.chatgpt.com/zh-Hans/docs/config-file/config-reference) 为准。线程级 `approvalPolicy=never` 与 MCP 工具级 `default_tools_approval_mode=approve` 作用于不同边界:前者禁止命令/文件修改请求进入交互审批,后者仅避免受限的 N7Web MCP 工具产生前端无法处理的第二次审批;真实执行授权仍完全由 N7Web 的一次性令牌、原文绑定和车辆安全链决定。
+20 -3
View File
@@ -52,11 +52,28 @@ docker compose -f deploy/server/compose.yaml ps
```
镜像固定安装 `@openai/codex@0.150.0-alpha.8`。AI 助手使用命名卷
`codex_home` 保存 Codex 运行状态;生产环境优先通过只读环境注入
`OPENAI_API_KEY`。若使用 ChatGPT 账号认证,可在首次启动后进入容器完成
Codex 登录,再重启服务。不要把 `auth.json` 复制进镜像或提交到仓库。
`codex_home` 保存 Codex 运行状态。N7Web 会在 App Server 启动握手期间验证模型认证,
认证缺失或初始化失败时不会把 `/api/health` 误报为 ready。
若 `.env` 同时设置 `OPENAI_BASE_URL` 和 `OPENAI_API_KEY`,且地址不是 OpenAI 官方
默认地址,N7Web 会为每个助手线程注入一个完整、固定的 OpenAI-compatible provider:
`base_url` 使用该地址,密钥只通过 provider 的 `env_key=OPENAI_API_KEY` 从子进程环境
读取,不会出现在命令行、状态或日志中。这一步是必须的;Codex 内置 `openai`
provider 不会把应用的 `OPENAI_BASE_URL` 自动当作模型端点。
若未设置自定义 `OPENAI_BASE_URL`,则使用 Codex 内置 OpenAI provider。已有
`codex_home/auth.json` 时复用现有登录;没有缓存登录但设置了 `OPENAI_API_KEY` 时,
N7Web 通过 App Server 的 API-key 登录接口完成初始化,Codex 会把认证状态写入
`codex_home`。因此该命名卷属于含凭据的持久数据,应限制管理员访问并纳入密钥轮换
流程。不要把 `auth.json` 复制进镜像、日志或仓库。仅设置 `CODEX_API_KEY` 不会让固定
版本的 App Server 自动登录。
若使用 ChatGPT 账号认证,推荐在无浏览器的服务器上执行 device-code 登录;普通网页
OAuth 依赖容器内 localhost 回调,容易在 headless 环境失败。登录完成后重启服务:
```bash
docker compose -f deploy/server/compose.yaml run --rm web codex login --device-auth
docker compose -f deploy/server/compose.yaml restart web
docker compose -f deploy/server/compose.yaml exec web codex login status
curl -fsS http://127.0.0.1:8511/api/health
```
@@ -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:
+43 -1
View File
@@ -20,9 +20,13 @@ EXTRA_MCP_NAME = next(
None,
)
INVALID_MCP_CONFIG = "--fake-invalid-mcp-config" in sys.argv[1:]
UNAUTHENTICATED = "--fake-unauthenticated" in sys.argv[1:]
LOGIN_FAILURE = "--fake-login-failure" in sys.argv[1:]
NO_OPENAI_AUTH_REQUIRED = "--fake-no-openai-auth-required" in sys.argv[1:]
thread_number = 0
turn_number = 0
waiting_turns: dict[str, str] = {}
account_present = not UNAUTHENTICATED
ENVIRONMENT_PATH.write_text(
json.dumps(sorted(os.environ), ensure_ascii=True),
@@ -35,8 +39,14 @@ def emit(message: dict[str, object]) -> None:
def trace(message: dict[str, object]) -> None:
recorded = message
if message.get("method") == "account/login/start":
recorded = dict(message)
params = message.get("params")
if isinstance(params, dict):
recorded["params"] = {**params, "apiKey": "<redacted>"}
with TRACE_PATH.open("a", encoding="utf-8") as stream:
stream.write(json.dumps(message, ensure_ascii=False) + "\n")
stream.write(json.dumps(recorded, ensure_ascii=False) + "\n")
for raw_line in sys.stdin:
@@ -58,6 +68,38 @@ for raw_line in sys.stdin:
)
elif method == "initialized":
continue
elif method == "account/read":
emit(
{
"id": request_id,
"result": {
"account": ({"type": "apiKey"} if account_present else None),
"requiresOpenaiAuth": not NO_OPENAI_AUTH_REQUIRED,
},
}
)
elif method == "account/login/start":
if LOGIN_FAILURE:
emit(
{
"id": request_id,
"error": {
"code": -32000,
"message": f"authentication rejected: {params.get('apiKey', '')}",
},
}
)
continue
if params.get("type") != "apiKey" or not isinstance(params.get("apiKey"), str):
emit(
{
"id": request_id,
"error": {"code": -32602, "message": "invalid login request"},
}
)
continue
account_present = True
emit({"id": request_id, "result": {"type": "apiKey"}})
elif method == "config/read":
mcp_override = next(
(
+279 -3
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import json
import sys
import tomllib
import traceback
from pathlib import Path
import pytest
@@ -30,6 +31,7 @@ def _client(tmp_path: Path) -> tuple[CodexAppServerClient, Path]:
reasoning_effort="low",
request_timeout=2,
startup_timeout=2,
environment={"OPENAI_BASE_URL": "https://api.openai.com/v1"},
mcp_server_path=MCP_SERVER,
)
return client, trace_path
@@ -81,15 +83,15 @@ def test_real_stdio_adapter_handshake_stream_and_transport_overrides(tmp_path: P
wire = [json.loads(line) for line in trace_path.read_text(encoding="utf-8").splitlines()]
methods = [message.get("method") for message in wire]
assert methods[:3] == ["initialize", "initialized", "config/read"]
assert methods[:4] == ["initialize", "initialized", "account/read", "config/read"]
assert "account/login/start" not in methods
thread_request = next(item for item in wire if item.get("method") == "thread/start")
params = thread_request["params"]
assert params["sandbox"] == "read-only"
assert params["approvalPolicy"] == "never"
assert params["model"] == "gpt-test-fast"
assert params["modelProvider"] == "fake-provider"
assert params["config"]["model_providers.fake-provider.supports_websockets"] is False
assert params["config"]["model_providers.fake-provider.stream_max_retries"] == 1
assert "model_providers" not in params["config"]
turn_request = next(item for item in wire if item.get("method") == "turn/start")
assert turn_request["params"]["sandboxPolicy"] == {
"type": "readOnly",
@@ -122,6 +124,280 @@ def test_interrupt_is_sent_and_waits_for_authoritative_terminal_event(tmp_path:
assert terminal["status"] == "interrupted"
def test_startup_bootstraps_missing_builtin_account_from_api_key_without_leaking_it(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
api_key = "test-model-provider-key-not-a-secret"
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
client = CodexAppServerClient(
cwd=tmp_path,
codex_command=[
sys.executable,
"-u",
str(FAKE_SERVER),
"--fake-unauthenticated",
],
request_timeout=2,
startup_timeout=2,
environment={"OPENAI_API_KEY": api_key},
mcp_server_path=MCP_SERVER,
)
try:
client.start(CALLBACK_URL, CALLBACK_TOKEN)
status = client.status
stderr = "\n".join(client.stderr_tail)
assert client.available is True
finally:
client.close()
trace_text = (tmp_path / "wire.jsonl").read_text(encoding="utf-8")
wire = [json.loads(line) for line in trace_text.splitlines()]
methods = [message.get("method") for message in wire]
assert methods[:6] == [
"initialize",
"initialized",
"account/read",
"account/login/start",
"account/read",
"config/read",
]
login = next(item for item in wire if item.get("method") == "account/login/start")
assert login["params"] == {"type": "apiKey", "apiKey": "<redacted>"}
assert api_key not in trace_text
assert api_key not in status
assert api_key not in stderr
assert api_key not in " ".join(client._build_command())
def test_startup_fails_fast_when_builtin_account_and_api_key_are_missing(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
client = CodexAppServerClient(
cwd=tmp_path,
codex_command=[
sys.executable,
"-u",
str(FAKE_SERVER),
"--fake-unauthenticated",
],
request_timeout=2,
startup_timeout=2,
mcp_server_path=MCP_SERVER,
)
with pytest.raises(CodexAppServerUnavailable, match="authentication is not configured"):
client.start(CALLBACK_URL, CALLBACK_TOKEN)
assert client.available is False
assert client.status == "failed"
client.close()
def test_startup_sanitizes_api_key_login_failure(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
api_key = "test-rejected-provider-key-not-a-secret"
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
client = CodexAppServerClient(
cwd=tmp_path,
codex_command=[
sys.executable,
"-u",
str(FAKE_SERVER),
"--fake-unauthenticated",
"--fake-login-failure",
],
request_timeout=2,
startup_timeout=2,
environment={"OPENAI_API_KEY": api_key},
mcp_server_path=MCP_SERVER,
)
with pytest.raises(
CodexAppServerUnavailable,
match="API-key authentication failed",
) as error:
client.start(CALLBACK_URL, CALLBACK_TOKEN)
assert api_key not in str(error.value)
assert "authentication rejected" not in str(error.value)
formatted_error = "".join(traceback.format_exception(error.value))
assert api_key not in formatted_error
assert "authentication rejected" not in formatted_error
assert api_key not in "\n".join(client.stderr_tail)
assert client.available is False
assert client.status == "failed"
client.close()
def test_startup_allows_provider_that_does_not_require_openai_account(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
client = CodexAppServerClient(
cwd=tmp_path,
codex_command=[
sys.executable,
"-u",
str(FAKE_SERVER),
"--fake-unauthenticated",
"--fake-no-openai-auth-required",
],
request_timeout=2,
startup_timeout=2,
mcp_server_path=MCP_SERVER,
)
try:
client.start(CALLBACK_URL, CALLBACK_TOKEN)
assert client.available is True
finally:
client.close()
wire = [
json.loads(line)
for line in (tmp_path / "wire.jsonl").read_text(encoding="utf-8").splitlines()
]
assert "account/login/start" not in [item.get("method") for item in wire]
def test_custom_base_url_uses_complete_thread_provider_and_environment_key_only(
tmp_path: Path,
) -> None:
api_key = "test-proxy-provider-key-not-a-secret"
base_url = "http://127.0.0.1:9876/v1"
client = CodexAppServerClient(
cwd=tmp_path,
codex_command=[sys.executable, "-u", str(FAKE_SERVER)],
model="gpt-test-proxy",
request_timeout=2,
startup_timeout=2,
environment={
"OPENAI_API_KEY": api_key,
"OPENAI_BASE_URL": base_url,
},
mcp_server_path=MCP_SERVER,
)
try:
client.start(CALLBACK_URL, CALLBACK_TOKEN)
client.start_thread(ephemeral=True)
assert client.available is True
finally:
client.close()
trace_text = (tmp_path / "wire.jsonl").read_text(encoding="utf-8")
wire = [json.loads(line) for line in trace_text.splitlines()]
methods = [item.get("method") for item in wire]
assert "account/read" not in methods
assert "account/login/start" not in methods
request = next(item for item in wire if item.get("method") == "thread/start")
assert request["params"]["modelProvider"] == "n7web_openai_compatible"
assert request["params"]["model"] == "gpt-test-proxy"
provider = request["params"]["config"]["model_providers"]["n7web_openai_compatible"]
assert provider == {
"name": "N7Web OpenAI-compatible",
"base_url": base_url,
"env_key": "OPENAI_API_KEY",
"wire_api": "responses",
"requires_openai_auth": False,
"supports_websockets": False,
"stream_max_retries": 1,
"request_max_retries": 1,
}
assert api_key not in trace_text
assert api_key not in " ".join(client._build_command())
def test_custom_base_url_fails_fast_without_its_environment_key(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
client = CodexAppServerClient(
cwd=tmp_path,
codex_command=[sys.executable, "-u", str(FAKE_SERVER)],
request_timeout=2,
startup_timeout=2,
environment={"OPENAI_BASE_URL": "http://127.0.0.1:9876/v1"},
mcp_server_path=MCP_SERVER,
)
with pytest.raises(
CodexAppServerUnavailable,
match="custom model provider authentication is not configured",
):
client.start(CALLBACK_URL, CALLBACK_TOKEN)
assert client.status == "failed"
assert client.available is False
client.close()
def test_explicit_environment_controls_model_and_proxy_provider(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("CODEX_ASSISTANT_MODEL", "ambient-model-must-not-win")
monkeypatch.setenv("CODEX_ASSISTANT_MODEL_PROVIDER", "ambient-provider-must-not-win")
monkeypatch.setenv("OPENAI_BASE_URL", "https://ambient.example.invalid/v1")
client = CodexAppServerClient(
cwd=tmp_path,
codex_command=[sys.executable, "-u", str(FAKE_SERVER)],
request_timeout=2,
startup_timeout=2,
environment={
"codex_assistant_model": "environment-model",
"codex_assistant_model_provider": "environment-provider",
"openai_api_key": "environment-provider-key-not-secret",
"openai_base_url": "http://localhost:9988/v1/",
},
mcp_server_path=MCP_SERVER,
)
try:
client.start(CALLBACK_URL, CALLBACK_TOKEN)
client.start_thread(ephemeral=True)
finally:
client.close()
wire = [
json.loads(line)
for line in (tmp_path / "wire.jsonl").read_text(encoding="utf-8").splitlines()
]
request = next(item for item in wire if item.get("method") == "thread/start")
assert request["params"]["model"] == "environment-model"
assert request["params"]["modelProvider"] == "n7web_openai_compatible"
provider = request["params"]["config"]["model_providers"]["n7web_openai_compatible"]
assert provider["base_url"] == "http://localhost:9988/v1"
@pytest.mark.parametrize(
"base_url",
[
"http://proxy.example.test/v1",
"https://user:password@proxy.example.test/v1",
"https://proxy.example.test/v1?api_key=not-allowed",
"file:///tmp/model",
],
)
def test_model_base_url_rejects_insecure_or_credential_bearing_values(
tmp_path: Path,
base_url: str,
) -> None:
with pytest.raises(ValueError, match="OPENAI_BASE_URL"):
CodexAppServerClient(
cwd=tmp_path,
codex_command=[sys.executable, "-u", str(FAKE_SERVER)],
environment={
"OPENAI_API_KEY": "test-provider-key-not-secret",
"OPENAI_BASE_URL": base_url,
},
mcp_server_path=MCP_SERVER,
)
def test_process_exit_during_turn_fails_the_handle_immediately(tmp_path: Path) -> None:
client, _trace_path = _client(tmp_path)
try: