402 lines
14 KiB
Python
402 lines
14 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import sys
|
|
import tomllib
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from cognitive_agent.infrastructure.codex.app_server import (
|
|
CodexAppServerClient,
|
|
CodexAppServerProtocolError,
|
|
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,
|
|
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 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[:3] == ["initialize", "initialized", "config/read"]
|
|
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
|
|
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_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)
|
|
client.close()
|
|
|
|
assert not first_workspace.exists()
|
|
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()
|
|
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
|