diff --git a/README.md b/README.md index 7496822..71abac2 100755 --- a/README.md +++ b/README.md @@ -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 的一次性令牌、原文绑定和车辆安全链决定。 diff --git a/deploy/server/README.md b/deploy/server/README.md index 02813bb..a785b9a 100755 --- a/deploy/server/README.md +++ b/deploy/server/README.md @@ -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 ``` diff --git a/src/cognitive_agent/infrastructure/codex/app_server.py b/src/cognitive_agent/infrastructure/codex/app_server.py index 0355f78..47ad4b9 100644 --- a/src/cognitive_agent/infrastructure/codex/app_server.py +++ b/src/cognitive_agent/infrastructure/codex/app_server.py @@ -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" diff --git a/src/cognitive_agent/server.py b/src/cognitive_agent/server.py index 48c212a..1aeff3d 100755 --- a/src/cognitive_agent/server.py +++ b/src/cognitive_agent/server.py @@ -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: diff --git a/tests/fixtures/fake_codex_app_server.py b/tests/fixtures/fake_codex_app_server.py index 078eb02..4b28830 100644 --- a/tests/fixtures/fake_codex_app_server.py +++ b/tests/fixtures/fake_codex_app_server.py @@ -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": ""} 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( ( diff --git a/tests/unit/test_codex_app_server_client.py b/tests/unit/test_codex_app_server_client.py index 719e4f3..758a642 100644 --- a/tests/unit/test_codex_app_server_client.py +++ b/tests/unit/test_codex_app_server_client.py @@ -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": ""} + 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: