798 lines
28 KiB
Python
798 lines
28 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import sys
|
|
import time
|
|
import tomllib
|
|
import traceback
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from cognitive_agent.infrastructure.codex.app_server import (
|
|
CodexAppServerClient,
|
|
CodexAppServerProtocolError,
|
|
CodexAppServerTimeout,
|
|
CodexAppServerUnavailable,
|
|
CodexTurnHandle,
|
|
)
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
FAKE_SERVER = ROOT / "tests" / "fixtures" / "fake_codex_app_server.py"
|
|
MCP_SERVER = ROOT / "src/cognitive_agent/infrastructure/codex/vehicle_mcp_server.py"
|
|
CALLBACK_URL = "http://127.0.0.1:8765/api/internal/codex/vehicle-control"
|
|
CALLBACK_TOKEN = "test-internal-token-123456"
|
|
|
|
|
|
def _client(tmp_path: Path) -> tuple[CodexAppServerClient, Path]:
|
|
trace_path = tmp_path / "wire.jsonl"
|
|
client = CodexAppServerClient(
|
|
cwd=tmp_path,
|
|
codex_command=[sys.executable, "-u", str(FAKE_SERVER)],
|
|
model="gpt-test-fast",
|
|
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
|
|
|
|
|
|
def _events_until_completed(handle: CodexTurnHandle) -> list[dict[str, object]]:
|
|
events: list[dict[str, object]] = []
|
|
event_queue = handle.events
|
|
while True:
|
|
event = event_queue.get(timeout=2)
|
|
events.append(event)
|
|
if event.get("method") == "turn/completed":
|
|
return events
|
|
|
|
|
|
def _wait_for_wire_method(trace_path: Path, method: str) -> list[dict[str, object]]:
|
|
deadline = time.monotonic() + 2
|
|
messages: list[dict[str, object]] = []
|
|
while time.monotonic() < deadline:
|
|
if trace_path.exists():
|
|
messages = [
|
|
json.loads(line)
|
|
for line in trace_path.read_text(encoding="utf-8").splitlines()
|
|
]
|
|
if any(message.get("method") == method for message in messages):
|
|
return messages
|
|
time.sleep(0.01)
|
|
raise AssertionError(f"timed out waiting for {method}: {messages}")
|
|
|
|
|
|
def test_real_stdio_adapter_handshake_stream_and_transport_overrides(tmp_path: Path) -> None:
|
|
client, trace_path = _client(tmp_path)
|
|
try:
|
|
client.start(CALLBACK_URL, CALLBACK_TOKEN)
|
|
assert client.available is True
|
|
assert client.status == "ready"
|
|
thread_id = client.start_thread(
|
|
developer_instructions="Test instructions",
|
|
ephemeral=True,
|
|
)
|
|
handle = client.start_turn(
|
|
thread_id,
|
|
"普通问题",
|
|
"request-token-123456",
|
|
application_context={
|
|
"catalog_revision": "rev-test",
|
|
"device_groups": [{"id": "roof", "operations": ["roof.set_shade_motion"]}],
|
|
},
|
|
)
|
|
events = _events_until_completed(handle)
|
|
terminal = client.finish_turn(handle, timeout=1)
|
|
unsubscribe_status = client.unsubscribe_thread(thread_id, timeout=1)
|
|
finally:
|
|
client.close()
|
|
|
|
assert client.status == "stopped"
|
|
assert unsubscribe_status == "unsubscribed"
|
|
assert terminal["status"] == "completed"
|
|
assert [
|
|
event["params"]["delta"]
|
|
for event in events
|
|
if event.get("method") == "item/agentMessage/delta"
|
|
] == ["协议", "正常"]
|
|
|
|
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[: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 "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",
|
|
"networkAccess": False,
|
|
}
|
|
assert "request-token-123456" in turn_request["params"]["input"][0]["text"]
|
|
assert '"catalog_revision":"rev-test"' in turn_request["params"]["input"][0]["text"]
|
|
assert "roof.set_shade_motion" in turn_request["params"]["input"][0]["text"]
|
|
unsubscribe_request = next(item for item in wire if item.get("method") == "thread/unsubscribe")
|
|
assert unsubscribe_request["params"] == {"threadId": thread_id}
|
|
|
|
|
|
def test_interrupt_is_sent_and_waits_for_authoritative_terminal_event(tmp_path: Path) -> None:
|
|
client, _trace_path = _client(tmp_path)
|
|
try:
|
|
client.start(CALLBACK_URL, CALLBACK_TOKEN)
|
|
thread_id = client.start_thread(developer_instructions="Test", ephemeral=True)
|
|
handle = client.start_turn(
|
|
thread_id,
|
|
"WAIT_FOR_INTERRUPT",
|
|
"request-token-123456",
|
|
)
|
|
client.interrupt(handle, timeout=1)
|
|
events = _events_until_completed(handle)
|
|
terminal = client.finish_turn(handle, timeout=1)
|
|
finally:
|
|
client.close()
|
|
|
|
assert events[-1]["method"] == "turn/completed"
|
|
assert terminal["status"] == "interrupted"
|
|
|
|
|
|
def test_late_thread_start_response_is_unsubscribed_after_rpc_timeout(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
client, trace_path = _client(tmp_path)
|
|
try:
|
|
client.start(CALLBACK_URL, CALLBACK_TOKEN)
|
|
with pytest.raises(CodexAppServerTimeout):
|
|
client.start_thread(
|
|
developer_instructions="DELAY_THREAD_START",
|
|
ephemeral=True,
|
|
timeout=0.02,
|
|
)
|
|
wire = _wait_for_wire_method(trace_path, "thread/unsubscribe")
|
|
finally:
|
|
client.close()
|
|
|
|
start = next(item for item in wire if item.get("method") == "thread/start")
|
|
unsubscribe = next(
|
|
item for item in wire if item.get("method") == "thread/unsubscribe"
|
|
)
|
|
assert start["id"] != unsubscribe["id"]
|
|
assert unsubscribe["params"] == {"threadId": "thread-1"}
|
|
|
|
|
|
def test_late_structured_turn_response_is_interrupted_without_backlog_leak(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
client, trace_path = _client(tmp_path)
|
|
try:
|
|
client.start(CALLBACK_URL, CALLBACK_TOKEN)
|
|
thread_id = client.start_thread(developer_instructions="Test", ephemeral=True)
|
|
with pytest.raises(CodexAppServerTimeout):
|
|
client.start_structured_turn(
|
|
thread_id,
|
|
"DELAY_TURN_START",
|
|
application_context={"capabilities": []},
|
|
output_schema={
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"required": [],
|
|
"properties": {},
|
|
},
|
|
timeout=0.02,
|
|
)
|
|
wire = _wait_for_wire_method(trace_path, "turn/interrupt")
|
|
_wait_for_wire_method(trace_path, "thread/unsubscribe")
|
|
deadline = time.monotonic() + 1
|
|
while time.monotonic() < deadline:
|
|
with client._lock:
|
|
if not client._turns and not client._event_backlog:
|
|
break
|
|
time.sleep(0.01)
|
|
with client._lock:
|
|
assert client._turns == {}
|
|
assert client._event_backlog == {}
|
|
finally:
|
|
client.close()
|
|
|
|
interrupt = next(item for item in wire if item.get("method") == "turn/interrupt")
|
|
assert interrupt["params"] == {"threadId": thread_id, "turnId": "turn-1"}
|
|
|
|
|
|
def test_abandon_turn_releases_handle_and_discards_all_late_events(tmp_path: Path) -> None:
|
|
client, _trace_path = _client(tmp_path)
|
|
handle = CodexTurnHandle(thread_id="thread-abandoned", turn_id="turn-abandoned")
|
|
key = (handle.thread_id, handle.turn_id)
|
|
with client._lock:
|
|
client._turns[key] = handle
|
|
|
|
client.abandon_turn(handle)
|
|
client._dispatch(
|
|
{
|
|
"method": "item/agentMessage/delta",
|
|
"params": {
|
|
"threadId": handle.thread_id,
|
|
"turnId": handle.turn_id,
|
|
"delta": "late",
|
|
},
|
|
}
|
|
)
|
|
client._dispatch(
|
|
{
|
|
"method": "turn/completed",
|
|
"params": {
|
|
"threadId": handle.thread_id,
|
|
"turn": {"id": handle.turn_id, "status": "interrupted"},
|
|
},
|
|
}
|
|
)
|
|
|
|
with client._lock:
|
|
assert key not in client._turns
|
|
assert key not in client._event_backlog
|
|
assert key in client._abandoned_turns
|
|
|
|
|
|
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:
|
|
client.start(CALLBACK_URL, CALLBACK_TOKEN)
|
|
thread_id = client.start_thread(developer_instructions="Test", ephemeral=True)
|
|
handle = client.start_turn(
|
|
thread_id,
|
|
"EXIT_DURING_TURN",
|
|
"request-token-123456",
|
|
)
|
|
events = [handle.events.get(timeout=2) for _ in range(2)]
|
|
event = next(item for item in events if item.get("method") == "error")
|
|
|
|
assert event["method"] == "error"
|
|
assert event["params"]["willRetry"] is False
|
|
with pytest.raises(CodexAppServerUnavailable, match="exited unexpectedly"):
|
|
client.finish_turn(handle, timeout=1)
|
|
finally:
|
|
client.close()
|
|
|
|
|
|
def test_non_retryable_error_is_terminal_even_without_turn_completed(tmp_path: Path) -> None:
|
|
client, _trace_path = _client(tmp_path)
|
|
try:
|
|
client.start(CALLBACK_URL, CALLBACK_TOKEN)
|
|
thread_id = client.start_thread(developer_instructions="Test", ephemeral=True)
|
|
handle = client.start_turn(
|
|
thread_id,
|
|
"FAIL_DURING_TURN",
|
|
"request-token-123456",
|
|
)
|
|
|
|
with pytest.raises(CodexAppServerProtocolError, match="without a terminal event"):
|
|
client.finish_turn(handle, timeout=1)
|
|
finally:
|
|
client.close()
|
|
|
|
|
|
def test_malformed_stdout_fails_the_initialize_request(tmp_path: Path) -> None:
|
|
source = "import time;print('not-json',flush=True);time.sleep(1)"
|
|
client = CodexAppServerClient(
|
|
cwd=tmp_path,
|
|
codex_command=[sys.executable, "-u", "-c", source],
|
|
request_timeout=1,
|
|
startup_timeout=1,
|
|
mcp_server_path=MCP_SERVER,
|
|
)
|
|
with pytest.raises(CodexAppServerProtocolError):
|
|
client.start(CALLBACK_URL, CALLBACK_TOKEN)
|
|
client.close()
|
|
|
|
|
|
def test_command_exposes_only_the_allowlisted_vehicle_mcp(tmp_path: Path) -> None:
|
|
client, _trace_path = _client(tmp_path)
|
|
rendered = " ".join(client._build_command())
|
|
assert "mcp_servers={ n7_vehicle" in rendered
|
|
assert 'enabled_tools = ["get_n7web_capabilities", "execute_cockpit_request"]' in rendered
|
|
assert "required = true" in rendered
|
|
assert 'default_tools_approval_mode = "approve"' in rendered
|
|
assert "--strict-config" in rendered
|
|
assert "shell_environment_policy.inherit=none" in rendered
|
|
assert "features.shell_tool=false" in rendered
|
|
assert "features.unified_exec=false" in rendered
|
|
assert "features.code_mode=false" in rendered
|
|
assert "features.computer_use=false" in rendered
|
|
assert "features.apps=false" in rendered
|
|
assert "features.plugins=false" in rendered
|
|
assert "notify=[]" in rendered
|
|
assert "analytics.enabled=false" in rendered
|
|
assert 'otel.exporter="none"' in rendered
|
|
assert 'web_search="disabled"' in rendered
|
|
assert "node_repl" not in rendered
|
|
assert "dynamicTools" not in rendered
|
|
|
|
|
|
def test_startup_discovers_and_disables_configured_mcp_servers(tmp_path: Path) -> None:
|
|
extra_name = 'side.car"tool'
|
|
client = CodexAppServerClient(
|
|
cwd=tmp_path,
|
|
codex_command=[
|
|
sys.executable,
|
|
"-u",
|
|
str(FAKE_SERVER),
|
|
f"--fake-extra-mcp={extra_name}",
|
|
],
|
|
request_timeout=2,
|
|
startup_timeout=2,
|
|
mcp_server_path=MCP_SERVER,
|
|
)
|
|
try:
|
|
client.start(CALLBACK_URL, CALLBACK_TOKEN)
|
|
assert client.available is True
|
|
rendered_command = client._build_command()
|
|
finally:
|
|
client.close()
|
|
|
|
quoted_name = json.dumps(extra_name, ensure_ascii=True)
|
|
mcp_override = next(
|
|
argument for argument in rendered_command if argument.startswith("mcp_servers={")
|
|
)
|
|
assert f", {quoted_name} = {{" in mcp_override
|
|
assert "enabled = false" in mcp_override
|
|
assert "raise SystemExit(0)" in mcp_override
|
|
parsed_override = tomllib.loads(f"mcp_servers = {mcp_override.removeprefix('mcp_servers=')}")
|
|
assert set(parsed_override["mcp_servers"]) == {"n7_vehicle", extra_name}
|
|
assert parsed_override["mcp_servers"][extra_name]["enabled"] is False
|
|
wire = [
|
|
json.loads(line)
|
|
for line in (tmp_path / "wire.jsonl").read_text(encoding="utf-8").splitlines()
|
|
]
|
|
assert [item.get("method") for item in wire].count("initialize") == 2
|
|
assert [item.get("method") for item in wire].count("config/read") == 2
|
|
|
|
|
|
def test_effective_mcp_validation_treats_missing_enabled_as_true(tmp_path: Path) -> None:
|
|
client, _trace_path = _client(tmp_path)
|
|
assert client._configured_mcp_servers(
|
|
{
|
|
"mcp_servers": {
|
|
"n7_vehicle": {"enabled": True},
|
|
"implicit-default": {},
|
|
}
|
|
}
|
|
) == {"n7_vehicle": True, "implicit-default": True}
|
|
|
|
|
|
def test_startup_fails_closed_when_effective_mcp_config_cannot_be_verified(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
client = CodexAppServerClient(
|
|
cwd=tmp_path,
|
|
codex_command=[
|
|
sys.executable,
|
|
"-u",
|
|
str(FAKE_SERVER),
|
|
"--fake-invalid-mcp-config",
|
|
],
|
|
request_timeout=2,
|
|
startup_timeout=2,
|
|
mcp_server_path=MCP_SERVER,
|
|
)
|
|
with pytest.raises(CodexAppServerProtocolError, match="no MCP servers"):
|
|
client.start(CALLBACK_URL, CALLBACK_TOKEN)
|
|
assert client.available is False
|
|
assert client.status == "failed"
|
|
client.close()
|
|
|
|
|
|
def test_child_environment_excludes_web_auth_and_hardware_secrets(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
blocked_names = {
|
|
"DEMO_AUTH_PASSWORD_HASH",
|
|
"DEMO_AUTH_SESSION_SECRET",
|
|
"COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN",
|
|
"COGNITIVE_AGENT_VEHICLE_CAN_ENABLED",
|
|
"COGNITIVE_AGENT_ZLGCAN_DLL",
|
|
"FAKE_CODEX_TRACE",
|
|
"ALL_PROXY",
|
|
"HTTP_PROXY",
|
|
"HTTPS_PROXY",
|
|
"NO_PROXY",
|
|
"all_proxy",
|
|
"http_proxy",
|
|
"https_proxy",
|
|
"no_proxy",
|
|
}
|
|
for name in blocked_names:
|
|
monkeypatch.setenv(name, f"secret-for-{name.lower()}")
|
|
monkeypatch.setenv("OPENAI_API_KEY", "model-provider-key")
|
|
client = CodexAppServerClient(
|
|
cwd=tmp_path,
|
|
codex_command=[sys.executable, "-u", str(FAKE_SERVER)],
|
|
request_timeout=2,
|
|
startup_timeout=2,
|
|
environment={
|
|
"DEMO_AUTH_SESSION_SECRET": "explicit-override-must-still-be-filtered",
|
|
"UNRELATED_PRIVATE_VALUE": "also-filtered",
|
|
},
|
|
mcp_server_path=MCP_SERVER,
|
|
)
|
|
try:
|
|
client.start(CALLBACK_URL, CALLBACK_TOKEN)
|
|
finally:
|
|
client.close()
|
|
|
|
child_names = set(json.loads((tmp_path / "environment.json").read_text(encoding="utf-8")))
|
|
assert "OPENAI_API_KEY" in child_names
|
|
assert "PATH" in {name.upper() for name in child_names}
|
|
assert "COGNITIVE_AGENT_CODEX_VEHICLE_CALLBACK_URL" in child_names
|
|
assert "COGNITIVE_AGENT_CODEX_VEHICLE_CALLBACK_TOKEN" in child_names
|
|
assert blocked_names.isdisjoint(child_names)
|
|
assert "UNRELATED_PRIVATE_VALUE" not in child_names
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"callback_url",
|
|
[
|
|
"http://127.attacker.example:8765/api/internal/codex/vehicle-control",
|
|
"http://192.0.2.1:8765/api/internal/codex/vehicle-control",
|
|
],
|
|
)
|
|
def test_callback_rejects_hosts_that_are_not_actual_loopback_addresses(
|
|
tmp_path: Path,
|
|
callback_url: str,
|
|
) -> None:
|
|
client, _trace_path = _client(tmp_path)
|
|
with pytest.raises(ValueError, match="loopback"):
|
|
client.start(callback_url, CALLBACK_TOKEN)
|
|
client.close()
|
|
|
|
|
|
def test_default_workspace_is_temporary_and_removed_on_close(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.delenv("CODEX_ASSISTANT_WORKSPACE", raising=False)
|
|
monkeypatch.delenv("COGNITIVE_AGENT_CODEX_WORKSPACE", raising=False)
|
|
client = CodexAppServerClient(
|
|
codex_command=[sys.executable, "-u", str(FAKE_SERVER)],
|
|
mcp_server_path=MCP_SERVER,
|
|
)
|
|
workspace = client._cwd
|
|
|
|
assert workspace.is_dir()
|
|
assert workspace != ROOT
|
|
assert workspace.name.startswith("n7web-codex-assistant-")
|
|
client.close()
|
|
assert not workspace.exists()
|
|
|
|
|
|
def test_client_can_restart_with_a_fresh_isolated_workspace(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.delenv("CODEX_ASSISTANT_WORKSPACE", raising=False)
|
|
monkeypatch.delenv("COGNITIVE_AGENT_CODEX_WORKSPACE", raising=False)
|
|
client = CodexAppServerClient(
|
|
codex_command=[sys.executable, "-u", str(FAKE_SERVER)],
|
|
request_timeout=2,
|
|
startup_timeout=2,
|
|
mcp_server_path=MCP_SERVER,
|
|
)
|
|
first_workspace = client._cwd
|
|
client.start(CALLBACK_URL, CALLBACK_TOKEN)
|
|
first_cleanup_executor = client._late_cleanup_executor
|
|
assert first_cleanup_executor is not None
|
|
assert first_cleanup_executor._max_workers == 4
|
|
client.close()
|
|
|
|
assert not first_workspace.exists()
|
|
assert client._late_cleanup_executor is None
|
|
client.start(CALLBACK_URL, CALLBACK_TOKEN)
|
|
second_workspace = client._cwd
|
|
try:
|
|
assert client.available is True
|
|
assert second_workspace != first_workspace
|
|
assert second_workspace.is_dir()
|
|
assert client._late_cleanup_executor is not first_cleanup_executor
|
|
assert client._late_cleanup_executor is not None
|
|
assert client._late_cleanup_executor._max_workers == 4
|
|
finally:
|
|
client.close()
|
|
assert not second_workspace.exists()
|
|
|
|
|
|
def test_default_instructions_allow_implicit_comfort_normalization(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
client, trace_path = _client(tmp_path)
|
|
try:
|
|
client.start(CALLBACK_URL, CALLBACK_TOKEN)
|
|
client.start_thread(ephemeral=True)
|
|
finally:
|
|
client.close()
|
|
|
|
wire = [json.loads(line) for line in trace_path.read_text(encoding="utf-8").splitlines()]
|
|
request = next(item for item in wire if item.get("method") == "thread/start")
|
|
instructions = request["params"]["developerInstructions"]
|
|
assert "Copy the opaque request_token" in instructions
|
|
assert "implicit comfort" in instructions
|
|
assert "binds it to the original" in instructions
|
|
assert "structured semantic plan" in instructions
|
|
assert "get_n7web_capabilities" in instructions
|
|
assert "original instruction unchanged" not in instructions
|