fix: configure Codex provider authentication
This commit is contained in:
@@ -125,9 +125,9 @@ codex login status
|
|||||||
make dev
|
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 的一次性令牌、原文绑定和车辆安全链决定。
|
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
@@ -52,11 +52,28 @@ docker compose -f deploy/server/compose.yaml ps
|
|||||||
```
|
```
|
||||||
|
|
||||||
镜像固定安装 `@openai/codex@0.150.0-alpha.8`。AI 助手使用命名卷
|
镜像固定安装 `@openai/codex@0.150.0-alpha.8`。AI 助手使用命名卷
|
||||||
`codex_home` 保存 Codex 运行状态;生产环境优先通过只读环境注入
|
`codex_home` 保存 Codex 运行状态。N7Web 会在 App Server 启动握手期间验证模型认证,
|
||||||
`OPENAI_API_KEY`。若使用 ChatGPT 账号认证,可在首次启动后进入容器完成
|
认证缺失或初始化失败时不会把 `/api/health` 误报为 ready。
|
||||||
Codex 登录,再重启服务。不要把 `auth.json` 复制进镜像或提交到仓库。
|
|
||||||
|
若 `.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
|
```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
|
docker compose -f deploy/server/compose.yaml exec web codex login status
|
||||||
curl -fsS http://127.0.0.1:8511/api/health
|
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_IO_ENCODING_ENV: Final = "PYTHONIOENCODING"
|
||||||
PYTHON_UTF8_ENV: Final = "PYTHONUTF8"
|
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")
|
_REQUEST_TOKEN_RE = re.compile(r"[A-Za-z0-9._~-]{8,256}\Z")
|
||||||
_PROCESS_ENVIRONMENT_ALLOWLIST: Final = frozenset(
|
_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 = {str(key): str(value) for key, value in os.environ.items()}
|
||||||
source.update({str(key): str(value) for key, value in (overrides or {}).items()})
|
source.update({str(key): str(value) for key, value in (overrides or {}).items()})
|
||||||
return {
|
environment: dict[str, str] = {}
|
||||||
key: value
|
for key, value in source.items():
|
||||||
for key, value in source.items()
|
normalized = key.upper()
|
||||||
if key.upper() in _PROCESS_ENVIRONMENT_ALLOWLIST or key.upper().startswith("LC_")
|
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:
|
def _validate_loopback_url(value: str) -> str:
|
||||||
@@ -233,6 +251,54 @@ def _validate_loopback_url(value: str) -> str:
|
|||||||
return url
|
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:
|
def _validate_secret(value: str, field_name: str, *, minimum: int = 16) -> str:
|
||||||
secret = value.strip()
|
secret = value.strip()
|
||||||
if len(secret) < minimum or len(secret) > 1024:
|
if len(secret) < minimum or len(secret) > 1024:
|
||||||
@@ -297,17 +363,47 @@ class CodexAppServerClient:
|
|||||||
if not self._mcp_server_path.is_file():
|
if not self._mcp_server_path.is_file():
|
||||||
raise ValueError(f"vehicle MCP server does not exist: {self._mcp_server_path}")
|
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 = (
|
self._model = (
|
||||||
model
|
model
|
||||||
|
or environment_model
|
||||||
or os.getenv("CODEX_ASSISTANT_MODEL")
|
or os.getenv("CODEX_ASSISTANT_MODEL")
|
||||||
or os.getenv("COGNITIVE_AGENT_CODEX_MODEL")
|
or os.getenv("COGNITIVE_AGENT_CODEX_MODEL")
|
||||||
or "gpt-5.6-luna"
|
or "gpt-5.6-luna"
|
||||||
).strip() or None
|
).strip() or None
|
||||||
self._model_provider = (
|
environment_model_provider = _environment_value(
|
||||||
os.getenv("CODEX_ASSISTANT_MODEL_PROVIDER")
|
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 os.getenv("COGNITIVE_AGENT_CODEX_MODEL_PROVIDER")
|
||||||
or ""
|
or ""
|
||||||
).strip() or None
|
).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 = (
|
self._reasoning_effort = (
|
||||||
reasoning_effort
|
reasoning_effort
|
||||||
or os.getenv("CODEX_ASSISTANT_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"),
|
or os.getenv("COGNITIVE_AGENT_CODEX_VEHICLE_TOOL_TIMEOUT_SECONDS"),
|
||||||
45.0,
|
45.0,
|
||||||
)
|
)
|
||||||
self._environment = {str(key): str(value) for key, value in (environment or {}).items()}
|
|
||||||
|
|
||||||
self._lifecycle_lock = threading.Lock()
|
self._lifecycle_lock = threading.Lock()
|
||||||
self._lock = threading.RLock()
|
self._lock = threading.RLock()
|
||||||
self._write_lock = threading.Lock()
|
self._write_lock = threading.Lock()
|
||||||
@@ -412,6 +506,9 @@ class CodexAppServerClient:
|
|||||||
|
|
||||||
command = self._build_command()
|
command = self._build_command()
|
||||||
child_environment = _allowlisted_process_environment(self._environment)
|
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_URL_ENV] = callback_url
|
||||||
child_environment[CALLBACK_TOKEN_ENV] = callback_token
|
child_environment[CALLBACK_TOKEN_ENV] = callback_token
|
||||||
child_environment[CALLBACK_TIMEOUT_ENV] = str(self._vehicle_tool_timeout)
|
child_environment[CALLBACK_TIMEOUT_ENV] = str(self._vehicle_tool_timeout)
|
||||||
@@ -478,6 +575,7 @@ class CodexAppServerClient:
|
|||||||
allow_starting=True,
|
allow_starting=True,
|
||||||
)
|
)
|
||||||
self._notify("initialized", {})
|
self._notify("initialized", {})
|
||||||
|
self._ensure_model_authentication(child_environment)
|
||||||
config_result = self._rpc(
|
config_result = self._rpc(
|
||||||
"config/read",
|
"config/read",
|
||||||
{"cwd": str(self._cwd), "includeLayers": False},
|
{"cwd": str(self._cwd), "includeLayers": False},
|
||||||
@@ -521,6 +619,81 @@ class CodexAppServerClient:
|
|||||||
self._failure = exc
|
self._failure = exc
|
||||||
raise
|
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:
|
def close(self) -> None:
|
||||||
"""Stop the child process and fail outstanding work."""
|
"""Stop the child process and fail outstanding work."""
|
||||||
|
|
||||||
@@ -577,12 +750,20 @@ class CodexAppServerClient:
|
|||||||
"model_verbosity": "low",
|
"model_verbosity": "low",
|
||||||
"model_reasoning_summary": "none",
|
"model_reasoning_summary": "none",
|
||||||
}
|
}
|
||||||
if self._model_provider and self._model_provider not in {"openai", "chatgpt"}:
|
if self._uses_openai_compatible_provider:
|
||||||
provider_prefix = f"model_providers.{self._model_provider}"
|
assert self._openai_base_url is not None
|
||||||
if self._disable_websockets:
|
config_overrides["model_providers"] = {
|
||||||
config_overrides[f"{provider_prefix}.supports_websockets"] = False
|
_OPENAI_COMPATIBLE_PROVIDER_ID: {
|
||||||
config_overrides[f"{provider_prefix}.stream_max_retries"] = self._stream_max_retries
|
"name": "N7Web OpenAI-compatible",
|
||||||
config_overrides[f"{provider_prefix}.request_max_retries"] = self._request_max_retries
|
"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
|
params["config"] = config_overrides
|
||||||
|
|
||||||
method = "thread/start"
|
method = "thread/start"
|
||||||
|
|||||||
@@ -1834,11 +1834,16 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
|||||||
def _handle_codex_vehicle_control(self) -> None:
|
def _handle_codex_vehicle_control(self) -> None:
|
||||||
"""Run an allowlisted MCP tool over the authenticated loopback bridge."""
|
"""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():
|
if not self._is_unproxied_loopback_request():
|
||||||
self._json(
|
self._json(
|
||||||
{"error": "local_access_required", "message": "仅允许本机 Codex 工具调用。"},
|
{"error": "local_access_required", "message": "仅允许本机 Codex 工具调用。"},
|
||||||
HTTPStatus.FORBIDDEN,
|
HTTPStatus.FORBIDDEN,
|
||||||
)
|
)
|
||||||
|
self._drain_rejected_request_body(content_length, max_bytes=32 * 1024)
|
||||||
return
|
return
|
||||||
if not self.assistant.authenticate_internal_request(
|
if not self.assistant.authenticate_internal_request(
|
||||||
self.headers.get("X-N7-Internal-Token", "")
|
self.headers.get("X-N7-Internal-Token", "")
|
||||||
@@ -1847,6 +1852,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
|||||||
{"error": "invalid_internal_token", "message": "Codex 工具认证失败。"},
|
{"error": "invalid_internal_token", "message": "Codex 工具认证失败。"},
|
||||||
HTTPStatus.FORBIDDEN,
|
HTTPStatus.FORBIDDEN,
|
||||||
)
|
)
|
||||||
|
self._drain_rejected_request_body(content_length, max_bytes=32 * 1024)
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
payload = self._read_json_body(max_bytes=32 * 1024)
|
payload = self._read_json_body(max_bytes=32 * 1024)
|
||||||
@@ -2846,17 +2852,22 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
|||||||
if slot_acquired:
|
if slot_acquired:
|
||||||
self.speech_preview_http_slots.release()
|
self.speech_preview_http_slots.release()
|
||||||
|
|
||||||
def _drain_rejected_request_body(self, content_length: int) -> None:
|
def _drain_rejected_request_body(
|
||||||
"""Gracefully finish an early-rejected POST without doing speech work.
|
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
|
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
|
replace an already-written HTTP error with TCP RST. Flush the rejection
|
||||||
first, then briefly discard a valid-sized body so clients reliably receive
|
first, then briefly discard a valid-sized body so clients reliably receive
|
||||||
the 4xx response. The preview/transcriber concurrency gates still run
|
the 4xx response. Callers choose a strict size ceiling, and authorization or
|
||||||
before any audio is read or decoded.
|
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
|
return
|
||||||
self.close_connection = True
|
self.close_connection = True
|
||||||
try:
|
try:
|
||||||
|
|||||||
+43
-1
@@ -20,9 +20,13 @@ EXTRA_MCP_NAME = next(
|
|||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
INVALID_MCP_CONFIG = "--fake-invalid-mcp-config" in sys.argv[1:]
|
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
|
thread_number = 0
|
||||||
turn_number = 0
|
turn_number = 0
|
||||||
waiting_turns: dict[str, str] = {}
|
waiting_turns: dict[str, str] = {}
|
||||||
|
account_present = not UNAUTHENTICATED
|
||||||
|
|
||||||
ENVIRONMENT_PATH.write_text(
|
ENVIRONMENT_PATH.write_text(
|
||||||
json.dumps(sorted(os.environ), ensure_ascii=True),
|
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:
|
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:
|
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:
|
for raw_line in sys.stdin:
|
||||||
@@ -58,6 +68,38 @@ for raw_line in sys.stdin:
|
|||||||
)
|
)
|
||||||
elif method == "initialized":
|
elif method == "initialized":
|
||||||
continue
|
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":
|
elif method == "config/read":
|
||||||
mcp_override = next(
|
mcp_override = next(
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
import tomllib
|
import tomllib
|
||||||
|
import traceback
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -30,6 +31,7 @@ def _client(tmp_path: Path) -> tuple[CodexAppServerClient, Path]:
|
|||||||
reasoning_effort="low",
|
reasoning_effort="low",
|
||||||
request_timeout=2,
|
request_timeout=2,
|
||||||
startup_timeout=2,
|
startup_timeout=2,
|
||||||
|
environment={"OPENAI_BASE_URL": "https://api.openai.com/v1"},
|
||||||
mcp_server_path=MCP_SERVER,
|
mcp_server_path=MCP_SERVER,
|
||||||
)
|
)
|
||||||
return client, trace_path
|
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()]
|
wire = [json.loads(line) for line in trace_path.read_text(encoding="utf-8").splitlines()]
|
||||||
methods = [message.get("method") for message in wire]
|
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")
|
thread_request = next(item for item in wire if item.get("method") == "thread/start")
|
||||||
params = thread_request["params"]
|
params = thread_request["params"]
|
||||||
assert params["sandbox"] == "read-only"
|
assert params["sandbox"] == "read-only"
|
||||||
assert params["approvalPolicy"] == "never"
|
assert params["approvalPolicy"] == "never"
|
||||||
assert params["model"] == "gpt-test-fast"
|
assert params["model"] == "gpt-test-fast"
|
||||||
assert params["modelProvider"] == "fake-provider"
|
assert params["modelProvider"] == "fake-provider"
|
||||||
assert params["config"]["model_providers.fake-provider.supports_websockets"] is False
|
assert "model_providers" not in params["config"]
|
||||||
assert params["config"]["model_providers.fake-provider.stream_max_retries"] == 1
|
|
||||||
turn_request = next(item for item in wire if item.get("method") == "turn/start")
|
turn_request = next(item for item in wire if item.get("method") == "turn/start")
|
||||||
assert turn_request["params"]["sandboxPolicy"] == {
|
assert turn_request["params"]["sandboxPolicy"] == {
|
||||||
"type": "readOnly",
|
"type": "readOnly",
|
||||||
@@ -122,6 +124,280 @@ def test_interrupt_is_sent_and_waits_for_authoritative_terminal_event(tmp_path:
|
|||||||
assert terminal["status"] == "interrupted"
|
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:
|
def test_process_exit_during_turn_fails_the_handle_immediately(tmp_path: Path) -> None:
|
||||||
client, _trace_path = _client(tmp_path)
|
client, _trace_path = _client(tmp_path)
|
||||||
try:
|
try:
|
||||||
|
|||||||
Reference in New Issue
Block a user