Files
q792602257andClaude Opus 4.6 70e0624a47 fix(host-agent): align MCP integration with mcp SDK 1.28.1 realities
Three final-review deviations closed:

I1 (session-end release): mcp SDK 1.28.1 exposes no per-session
shutdown callback (only a server-level lifespan). Lower the
McpBusyTracker default TTL from 60s to 20s and update spec §6.5,
Q5/R3, D9, and docs/MCP_INTEGRATION.md concurrency section to
document the TTL-only recovery path. 20s is short enough to recover
within one 30s heartbeat interval but long enough that an active
session does not lose its lease during normal operator pauses.

I2 (JSON-RPC error shape): FastMCP Tool.run wraps every non-
UrlElicitationRequiredError exception (including McpError with typed
ErrorData) into ToolError, which the lowlevel call_tool handler
serializes as CallToolResult(isError=true, content=[TextContent(...)]).
There is no public path that surfaces JSON-RPC -32000 with structured
data.busy_owner from a tool call site. Update spec §7 error matrix
and docs/MCP_INTEGRATION.md error table to document the actual wire
shape; busy_owner now lives in the text content.

I3 (typing): mcp_server: Any = None -> FastMCP | None = None via
TYPE_CHECKING, keeping the mcp import lazy (matches precedent
elsewhere in the codebase) while adding static type checking at the
create_console_app boundary.

Tests added (4):
- test_default_ttl_is_20_seconds — locks I1's new default TTL
- test_default_ttl_recovers_dead_session_within_one_window — locks
  I1's recovery semantics (lease sweeped on next read after 20s)
- test_busy_error_wire_shape_is_calltoolresult_iserror — pins I2's
  wire envelope via Tool.run + lowlevel Server._make_error_result
- test_busy_error_text_includes_cloud_assignment_owner — same for
  the cloud_assignment busy_owner branch

Full non-integration suite: 697 passed / 54 deselected (was 693 / 54).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-21 16:25:35 +08:00

786 lines
26 KiB
Python

from __future__ import annotations
import asyncio
import socket
from contextlib import suppress
from datetime import UTC, datetime, timedelta
import httpx
import pytest
from starlette.testclient import TestClient
from cloud.internal_api.models import (
AssignmentModel,
DeviceEnrollmentResponse,
HostEnrollmentResponse,
)
from device.manager import DeviceManager
from host_agent.app import HostAgentApplication, create_application
from host_agent.assignment import AssignmentExecutor
from host_agent.config import HostAgentConfig
from host_agent.execution import create_execution_factories
from host_agent.history import ConsoleHistoryStore
from host_agent.identity import HostIdentityStore
from host_agent.instance_lock import InstanceAlreadyRunningError
from host_agent.local_account import LocalAccountStore
from host_agent.mcp_lock import McpBusyTracker
from host_agent.mcp_token import McpTokenStore
from host_agent.status import AgentStatusTracker
from host_agent.web.app import create_console_app
from host_agent.web.auth import SessionManager
from host_agent.web.mcp import build_mcp_server
from storage.artifact_store import ArtifactStore
from storage.device_config import DeviceConfigStore
from storage.task_metadata import TaskMetadataStore
from storage.timeline import Timeline
def _free_loopback_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
probe.bind(("127.0.0.1", 0))
return probe.getsockname()[1]
def _config() -> HostAgentConfig:
return HostAgentConfig(
control_plane_url="https://control.example",
host_id="host-a",
token="secret",
)
def _assignment() -> AssignmentModel:
return AssignmentModel(
task_id="task-a",
attempt=1,
lease_id="lease-a",
lease_expires_at=datetime.now(UTC) + timedelta(seconds=30),
host_id="host-a",
device_id="device-a",
goal="open settings",
)
def test_create_application_composes_host_agent_services(tmp_path, monkeypatch) -> None:
monkeypatch.chdir(tmp_path)
application = create_application(config=_config(), manager=DeviceManager())
assert isinstance(application, HostAgentApplication)
asyncio.run(application.client.aclose())
def test_create_application_loads_persisted_device_configuration(
tmp_path,
monkeypatch,
) -> None:
monkeypatch.chdir(tmp_path)
store = DeviceConfigStore(tmp_path / "devices.sqlite3")
store.add(
device_id="device-a",
name="Lab iPhone",
driver_type="wda",
connection_info={"url": "http://wda.local"},
)
application = create_application(
config=_config(),
device_config_store=store,
)
devices = application.heartbeat.manager.list_devices()
assert [(device.id, device.name, device.driver_type) for device in devices] == [
("device-a", "Lab iPhone", "wda")
]
assert devices[0].connection_info == {"url": "http://wda.local"}
asyncio.run(application.client.aclose())
def test_create_application_enrolls_host_and_devices_before_managed_startup(
tmp_path,
monkeypatch,
) -> None:
monkeypatch.chdir(tmp_path)
store = DeviceConfigStore(tmp_path / "devices.sqlite3")
store.add(
device_id="local-device-a",
name="Lab iPhone",
driver_type="wda",
connection_info={"server_url": "http://127.0.0.1:4723"},
)
events: list[str] = []
class EnrollmentClient:
def __init__(self) -> None:
self.config = HostAgentConfig(
control_plane_url="https://control.example",
enrollment_managed=True,
)
def enroll_host(self, **payload):
events.append(f"host:{payload['agent_instance_id']}")
return HostEnrollmentResponse(host_id="host-cloud-a")
def enroll_device(self, **payload):
events.append(f"device:{payload['local_device_id']}")
return DeviceEnrollmentResponse(device_id="device-cloud-a")
def close(self):
raise AssertionError("injected client must not be closed")
identity_store = HostIdentityStore(tmp_path / "host_identity.json")
enrollment_client = EnrollmentClient()
application = create_application(
config=enrollment_client.config,
device_config_store=store,
identity_store=identity_store,
enrollment_client=enrollment_client, # type: ignore[arg-type]
)
assert events[0].startswith("host:agent-")
assert events[1] == "device:local-device-a"
assert application.client.config.host_id == "host-cloud-a"
assert application.client.config.enrollment_managed is True
assert [device.id for device in application.heartbeat.manager.list_devices()] == [
"device-cloud-a"
]
assert store.get("local-device-a")["cloud_device_id"] == "device-cloud-a"
assert identity_store.load().host_id == "host-cloud-a"
asyncio.run(application.client.aclose())
def test_managed_restart_reuses_identity_and_recovers_device_mapping(
tmp_path,
monkeypatch,
) -> None:
monkeypatch.chdir(tmp_path)
store = DeviceConfigStore(tmp_path / "devices.sqlite3")
store.add(
device_id="local-device-a",
driver_type="wda",
connection_info={},
)
identity_store = HostIdentityStore(tmp_path / "host_identity.json")
identity_store.complete(identity_store.load_or_create(), "host-cloud-a")
events: list[str] = []
class EnrollmentClient:
config = HostAgentConfig(
control_plane_url="https://control.example",
identity_path=tmp_path / "host_identity.json",
enrollment_managed=True,
)
def enroll_host(self, **payload):
raise AssertionError("completed identity must skip Host enrollment")
def enroll_device(self, **payload):
events.append(payload["local_device_id"])
return DeviceEnrollmentResponse(device_id="device-cloud-a")
def close(self):
return None
enrollment_client = EnrollmentClient()
application = create_application(
config=enrollment_client.config,
device_config_store=store,
identity_store=identity_store,
enrollment_client=enrollment_client, # type: ignore[arg-type]
)
assert events == ["local-device-a"]
assert application.client.config.host_id == "host-cloud-a"
assert store.get("local-device-a")["cloud_device_id"] == "device-cloud-a"
asyncio.run(application.client.aclose())
def test_shutdown_cancels_long_poll_and_sends_final_heartbeat() -> None:
async def scenario() -> None:
claim_started = asyncio.Event()
claim_cancelled = asyncio.Event()
events: list[str] = []
class BlockingClient:
async def claim(self):
claim_started.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
claim_cancelled.set()
raise
async def aclose(self):
events.append("closed")
class RecordingHeartbeat:
async def run(self, stop):
await stop.wait()
async def sync_once(self):
events.append("final-heartbeat")
class IdleProcessor:
async def process(self, assignment):
raise AssertionError("no assignment expected")
def request_stop(self):
events.append("stop-work")
stop = asyncio.Event()
application = HostAgentApplication(
client=BlockingClient(), # type: ignore[arg-type]
heartbeat=RecordingHeartbeat(), # type: ignore[arg-type]
processor=IdleProcessor(), # type: ignore[arg-type]
)
running = asyncio.create_task(application.run_async(stop))
await claim_started.wait()
stop.set()
await asyncio.wait_for(running, timeout=1)
assert claim_cancelled.is_set()
assert events == ["stop-work", "final-heartbeat", "closed"]
asyncio.run(scenario())
def test_shutdown_interrupts_active_work_before_final_heartbeat() -> None:
async def scenario() -> None:
processing_started = asyncio.Event()
processing_stopped = asyncio.Event()
events: list[str] = []
claims = 0
class AssignedClient:
async def claim(self):
nonlocal claims
claims += 1
if claims == 1:
return _assignment()
await asyncio.Event().wait()
async def aclose(self):
events.append("closed")
class RecordingHeartbeat:
async def run(self, stop):
await stop.wait()
async def sync_once(self):
events.append("final-heartbeat")
class CooperativeProcessor:
async def process(self, assignment):
processing_started.set()
await processing_stopped.wait()
events.append("work-finished")
def request_stop(self):
events.append("stop-work")
processing_stopped.set()
stop = asyncio.Event()
application = HostAgentApplication(
client=AssignedClient(), # type: ignore[arg-type]
heartbeat=RecordingHeartbeat(), # type: ignore[arg-type]
processor=CooperativeProcessor(), # type: ignore[arg-type]
)
running = asyncio.create_task(application.run_async(stop))
await processing_started.wait()
stop.set()
await asyncio.wait_for(running, timeout=1)
assert events.index("work-finished") < events.index("final-heartbeat")
assert events[-1] == "closed"
asyncio.run(scenario())
def test_final_heartbeat_failure_does_not_prevent_client_close() -> None:
async def scenario() -> None:
closed = False
class StoppedClient:
async def claim(self):
raise AssertionError("polling must not start")
async def aclose(self):
nonlocal closed
closed = True
class FailingHeartbeat:
async def run(self, stop):
await stop.wait()
async def sync_once(self):
raise OSError("control plane unavailable")
class IdleProcessor:
def request_stop(self):
return None
stop = asyncio.Event()
stop.set()
application = HostAgentApplication(
client=StoppedClient(), # type: ignore[arg-type]
heartbeat=FailingHeartbeat(), # type: ignore[arg-type]
processor=IdleProcessor(), # type: ignore[arg-type]
)
await application.run_async(stop)
assert closed
asyncio.run(scenario())
def test_main_task_cancellation_waits_for_active_work_shutdown() -> None:
async def scenario() -> None:
processing_started = asyncio.Event()
processing_stopped = asyncio.Event()
events: list[str] = []
class AssignedClient:
async def claim(self):
return _assignment()
async def aclose(self):
events.append("closed")
class RecordingHeartbeat:
async def run(self, stop):
await stop.wait()
async def sync_once(self):
events.append("final-heartbeat")
class CooperativeProcessor:
async def process(self, assignment):
processing_started.set()
await processing_stopped.wait()
events.append("work-finished")
def request_stop(self):
processing_stopped.set()
application = HostAgentApplication(
client=AssignedClient(), # type: ignore[arg-type]
heartbeat=RecordingHeartbeat(), # type: ignore[arg-type]
processor=CooperativeProcessor(), # type: ignore[arg-type]
)
running = asyncio.create_task(application.run_async())
await processing_started.wait()
running.cancel()
with suppress(asyncio.CancelledError):
await running
assert events == ["work-finished", "final-heartbeat", "closed"]
asyncio.run(scenario())
def test_console_serves_http_and_shuts_down_cleanly(tmp_path) -> None:
async def scenario() -> None:
port = _free_loopback_port()
config = HostAgentConfig(
control_plane_url="https://control.example",
host_id="host-a",
token="secret",
identity_path=tmp_path / "host_identity.json",
local_account_path=tmp_path / "host_local_account.json",
console_bind_host="127.0.0.1",
console_port=port,
)
application = create_application(config=config, manager=DeviceManager())
assert application.console_server is not None
claim_started = asyncio.Event()
claim_cancelled = asyncio.Event()
events: list[str] = []
class BlockingClient:
async def claim(self):
claim_started.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
claim_cancelled.set()
raise
async def aclose(self):
events.append("closed")
class RecordingHeartbeat:
async def run(self, stop):
await stop.wait()
async def sync_once(self):
events.append("final-heartbeat")
class IdleProcessor:
async def process(self, assignment):
raise AssertionError("no assignment expected")
def request_stop(self):
events.append("stop-work")
application.client = BlockingClient() # type: ignore[assignment]
application.heartbeat = RecordingHeartbeat() # type: ignore[assignment]
application.processor = IdleProcessor() # type: ignore[assignment]
stop = asyncio.Event()
running = asyncio.create_task(application.run_async(stop))
await claim_started.wait()
response: httpx.Response | None = None
async with httpx.AsyncClient() as http_client:
loop = asyncio.get_running_loop()
deadline = loop.time() + 5
while loop.time() < deadline:
try:
response = await http_client.get(
f"http://127.0.0.1:{port}/login", timeout=0.5
)
except httpx.TransportError:
await asyncio.sleep(0.05)
continue
break
assert response is not None
assert response.status_code == 200
assert "Login" in response.text
stop.set()
await asyncio.wait_for(running, timeout=5)
assert claim_cancelled.is_set()
assert events == ["stop-work", "final-heartbeat", "closed"]
assert application.console_server.should_exit is True
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
probe.settimeout(0.5)
with suppress(ConnectionRefusedError, OSError):
probe.connect(("127.0.0.1", port))
raise AssertionError("console socket should be closed after shutdown")
asyncio.run(scenario())
def test_console_is_created_by_default(tmp_path, monkeypatch) -> None:
monkeypatch.chdir(tmp_path)
application = create_application(config=_config(), manager=DeviceManager())
assert application.console_server is not None
asyncio.run(application.client.aclose())
def test_dependency_supervisor_is_none_when_disabled(tmp_path, monkeypatch) -> None:
monkeypatch.chdir(tmp_path)
application = create_application(config=_config(), manager=DeviceManager())
assert application.dependency_supervisor is None
asyncio.run(application.client.aclose())
def test_dependency_supervisor_constructed_when_enabled_with_no_deps(
tmp_path, monkeypatch
) -> None:
monkeypatch.chdir(tmp_path)
config = HostAgentConfig(
control_plane_url="https://control.example",
host_id="host-a",
token="secret",
dependency_supervisor_enabled=True,
)
application = create_application(config=config, manager=DeviceManager())
assert application.dependency_supervisor is not None
assert application.dependency_supervisor.dependencies == []
asyncio.run(application.client.aclose())
def test_dependency_supervisor_constructed_when_enabled_with_appium_only(
tmp_path, monkeypatch
) -> None:
monkeypatch.chdir(tmp_path)
config = HostAgentConfig(
control_plane_url="https://control.example",
host_id="host-a",
token="secret",
dependency_supervisor_enabled=True,
appium_supervised=True,
appium_host="127.0.0.1",
appium_port=4723,
)
application = create_application(config=config, manager=DeviceManager())
assert application.dependency_supervisor is not None
deps = application.dependency_supervisor.dependencies
assert [dep.name for dep in deps] == ["appium"]
asyncio.run(application.client.aclose())
def test_run_async_starts_supervisor_before_first_heartbeat_connect() -> None:
async def scenario() -> None:
events: list[str] = []
class SupervisedNoOp:
def __init__(self) -> None:
self.started = False
self.stopped = False
async def start(self) -> None:
self.started = True
events.append("supervisor-start")
async def run(self, stop: asyncio.Event) -> None:
events.append("supervisor-run-entered")
await stop.wait()
async def stop(self) -> None:
self.stopped = True
events.append("supervisor-stop")
class BlockingClient:
async def claim(self):
await asyncio.Event().wait()
async def aclose(self):
events.append("closed")
class RecordingHeartbeat:
def __init__(self) -> None:
self.connect_called = False
def connect_devices(self) -> None:
self.connect_called = True
events.append("connect-devices")
async def run(self, stop: asyncio.Event) -> None:
# Mirror HeartbeatSynchronizer.run which calls connect_devices()
# at the very top — supervisor must have started already.
self.connect_devices()
await stop.wait()
async def sync_once(self):
events.append("final-heartbeat")
class IdleProcessor:
async def process(self, assignment):
raise AssertionError("no assignment expected")
def request_stop(self):
events.append("stop-work")
supervisor = SupervisedNoOp()
application = HostAgentApplication(
client=BlockingClient(), # type: ignore[arg-type]
heartbeat=RecordingHeartbeat(), # type: ignore[arg-type]
processor=IdleProcessor(), # type: ignore[arg-type]
dependency_supervisor=supervisor, # type: ignore[arg-type]
)
stop = asyncio.Event()
running = asyncio.create_task(application.run_async(stop))
# Yield long enough for startup sequencing to land.
await asyncio.sleep(0.05)
stop.set()
await asyncio.wait_for(running, timeout=1.0)
assert supervisor.started is True
assert supervisor.stopped is True
# Supervisor startup must precede the heartbeat's connect_devices().
assert events.index("supervisor-start") < events.index("connect-devices")
# Supervisor stop must run before client close.
assert events.index("supervisor-stop") < events.index("closed")
asyncio.run(scenario())
def test_second_create_application_against_held_lock_raises_before_enrollment(
tmp_path, monkeypatch
) -> None:
monkeypatch.chdir(tmp_path)
identity_path = tmp_path / "host_identity.json"
config = HostAgentConfig(
control_plane_url="https://control.example",
host_id="host-a",
token="secret",
identity_path=identity_path,
)
class TrackingEnrollmentClient:
def __init__(self) -> None:
self.config = config
self.calls: list[str] = []
def enroll_host(self, **payload):
self.calls.append("host")
return HostEnrollmentResponse(host_id="host-a")
def enroll_device(self, **payload):
self.calls.append("device")
return DeviceEnrollmentResponse(device_id="device-a")
def close(self) -> None:
return None
first_client = TrackingEnrollmentClient()
first_app = create_application(
config=config,
identity_store=HostIdentityStore(identity_path),
enrollment_client=first_client, # type: ignore[arg-type]
)
try:
second_client = TrackingEnrollmentClient()
with pytest.raises(InstanceAlreadyRunningError) as info:
create_application(
config=config,
identity_store=HostIdentityStore(identity_path),
enrollment_client=second_client, # type: ignore[arg-type]
)
assert info.value.lock_path == identity_path.parent / "host_agent.lock"
assert second_client.calls == []
finally:
asyncio.run(first_app.client.aclose())
def test_create_application_with_independent_identity_paths_coexist(
tmp_path, monkeypatch
) -> None:
monkeypatch.chdir(tmp_path)
config_a = HostAgentConfig(
control_plane_url="https://control.example",
host_id="host-a",
token="secret",
identity_path=tmp_path / "identity-a" / "host_identity.json",
)
config_b = HostAgentConfig(
control_plane_url="https://control.example",
host_id="host-b",
token="secret",
identity_path=tmp_path / "identity-b" / "host_identity.json",
)
app_a = create_application(config=config_a, manager=DeviceManager())
try:
app_b = create_application(config=config_b, manager=DeviceManager())
asyncio.run(app_b.client.aclose())
finally:
asyncio.run(app_a.client.aclose())
def test_create_application_wires_mcp_components(tmp_path, monkeypatch) -> None:
"""create_application produces a console app with /mcp mounted (auth-protected)
and persists the host_mcp_token.json file alongside the identity."""
monkeypatch.chdir(tmp_path)
config = _config()
config_store = DeviceConfigStore(tmp_path / "devices.sqlite3")
identity_store = HostIdentityStore(config.identity_path)
history_store = ConsoleHistoryStore(
tmp_path / "host_console_history.sqlite3",
limit=config.console_history_limit,
)
metadata_store = TaskMetadataStore(db_path=config.task_progress_db_path)
timeline = Timeline(ArtifactStore(root=config.task_artifact_dir))
status_tracker = AgentStatusTracker()
application = create_application(
config=config,
device_config_store=config_store,
identity_store=identity_store,
manager=DeviceManager(),
)
# Token file must exist after create_application.
assert (config.identity_path.parent / "host_mcp_token.json").exists()
# Heartbeat must hold the in-process McpBusyTracker.
assert application.heartbeat.mcp_busy_tracker is not None
# Build the same console app the production path builds and verify /mcp
# is mounted (responds 401, not 404) without a bearer token.
mcp_token_store = McpTokenStore(config.identity_path.parent / "host_mcp_token.json")
mcp_token_store.load_or_create()
mcp_busy_tracker = McpBusyTracker(ttl_seconds=20.0)
mcp_server = build_mcp_server(
manager=application.heartbeat.manager,
mcp_busy_tracker=mcp_busy_tracker,
status_tracker=status_tracker,
)
console_app = create_console_app(
config=config,
manager=application.heartbeat.manager,
config_store=config_store,
local_account_store=LocalAccountStore(config.local_account_path),
identity_store=identity_store,
history_store=history_store,
status_tracker=status_tracker,
session_manager=SessionManager(ttl_seconds=config.console_session_ttl_seconds),
enrollment_client=None,
host_client=application.client,
metadata_store=metadata_store,
timeline=timeline,
executor=AssignmentExecutor(
create_execution_factories(
application.heartbeat.manager,
metadata_store=metadata_store,
timeline=timeline,
host_agent_config=config,
),
mcp_busy_tracker=mcp_busy_tracker,
),
mcp_server=mcp_server,
mcp_token_store=mcp_token_store,
mcp_busy_tracker=mcp_busy_tracker,
)
with TestClient(console_app) as client:
resp = client.post("/mcp/")
assert resp.status_code == 401 # auth required, not 404
asyncio.run(application.client.aclose())
def test_lock_released_after_run_async_allows_restart(tmp_path, monkeypatch) -> None:
monkeypatch.chdir(tmp_path)
identity_path = tmp_path / "host_identity.json"
config = HostAgentConfig(
control_plane_url="https://control.example",
host_id="host-a",
token="secret",
identity_path=identity_path,
)
first_app = create_application(config=config, manager=DeviceManager())
class StoppedClient:
async def claim(self):
raise AssertionError("polling must not start")
async def aclose(self):
return None
class FastHeartbeat:
async def run(self, stop):
await stop.wait()
async def sync_once(self):
return None
class IdleProcessor:
async def process(self, assignment):
raise AssertionError("no assignment expected")
def request_stop(self):
return None
first_app.client = StoppedClient() # type: ignore[assignment]
first_app.heartbeat = FastHeartbeat() # type: ignore[assignment]
first_app.processor = IdleProcessor() # type: ignore[assignment]
stop = asyncio.Event()
stop.set()
asyncio.run(first_app.run_async(stop))
# Lock must be free now; a fresh create_application against the same
# identity_path must succeed (simulates a clean restart).
second_app = create_application(config=config, manager=DeviceManager())
asyncio.run(second_app.client.aclose())