diff --git a/.env.example b/.env.example index d272993..e75bf7b 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,7 @@ POSTGRES_PASSWORD=change-me-database-password CLOUD_API_PORT=8001 CLOUD_PUBLIC_CREDENTIALS_JSON=[{"principal_id":"local-sdk","token":"change-me-public-token","scopes":["tasks:submit","tasks:read","pool:read","plugins:read","plugins:admin"]}] CLOUD_HOST_CREDENTIALS_JSON=[{"principal_id":"local-host-agent","token":"change-me-host-token","scopes":[],"host_id":"host-local"}] +CLOUD_ENROLLMENT_TOKENS_JSON=[{"principal_id":"edge-installer","token":"change-me-enrollment-token"}] CLOUD_SCHEDULER_INTERVAL_SECONDS=1 CLOUD_LEASE_REAPER_INTERVAL_SECONDS=5 CLOUD_LEASE_DURATION_SECONDS=60 @@ -19,6 +20,9 @@ CLOUD_MAX_TASK_ATTEMPTS=3 HOST_AGENT_HOST_ID=host-local HOST_AGENT_TOKEN=change-me-host-token +HOST_AGENT_ENROLLMENT_TOKEN= +HOST_AGENT_IDENTITY_PATH=/app/tasks/host_identity.json +HOST_AGENT_DISPLAY_NAME= HOST_AGENT_TASKS_PATH=./tasks HOST_AGENT_HEARTBEAT_INTERVAL_SECONDS=30 HOST_AGENT_POLL_TIMEOUT_SECONDS=20 diff --git a/apps/cloud-api/cloud_api/app.py b/apps/cloud-api/cloud_api/app.py index c76a38a..781c2b6 100644 --- a/apps/cloud-api/cloud_api/app.py +++ b/apps/cloud-api/cloud_api/app.py @@ -11,7 +11,12 @@ from fastapi import FastAPI, status from fastapi import Request from fastapi.responses import JSONResponse -from cloud.auth import create_auth_provider +from cloud.auth import ( + ChainedAuthProvider, + ConfiguredEnrollmentTokenProvider, + RepositoryHostAuthProvider, + create_auth_provider, +) from cloud.config import CloudConfig from cloud.control_config import ( CloudControlConfig, @@ -73,11 +78,20 @@ def create_app( control_config = config or load_control_config() validate_control_config(control_config) build_database = database_factory or _default_database_factory - auth_provider = create_auth_provider( + configured_auth_provider = create_auth_provider( control_config.credentials, allow_insecure_anonymous=control_config.allow_insecure_anonymous, ) repository = _RepositoryProxy() + auth_provider = ChainedAuthProvider( + ( + configured_auth_provider, + RepositoryHostAuthProvider(repository), # type: ignore[arg-type] + ) + ) + enrollment_auth_provider = ConfiguredEnrollmentTokenProvider( + control_config.enrollment_credentials + ) domain_config = CloudConfig( lease_duration_seconds=control_config.lease_duration_seconds, ) @@ -208,6 +222,7 @@ def create_app( create_internal_router( pool=pool, auth_provider=auth_provider, + enrollment_auth_provider=enrollment_auth_provider, lease_duration_seconds=control_config.lease_duration_seconds, ) ) diff --git a/apps/cloud-api/tests/test_app.py b/apps/cloud-api/tests/test_app.py index 5f4ce90..f3afdeb 100644 --- a/apps/cloud-api/tests/test_app.py +++ b/apps/cloud-api/tests/test_app.py @@ -8,6 +8,7 @@ from fastapi.testclient import TestClient import cloud_api.app as app_module from cloud_api.app import create_app +from cloud.auth import BearerCredential, EnrollmentCredential, digest_token from cloud.control_config import CloudConfigurationError, CloudControlConfig from cloud.database import CloudDatabase from cloud.pool import PooledDevice @@ -23,6 +24,156 @@ def test_create_app_returns_independent_cloud_application() -> None: paths = set(app.openapi()["paths"]) assert "/v1/tasks" in paths assert "/internal/v1/hosts/{host_id}/heartbeat" in paths + assert "/internal/v1/enrollments" in paths + assert "/internal/v1/hosts/{host_id}/devices/enroll" in paths + + +def test_managed_host_enrollment_device_mapping_and_restart_authentication( + tmp_path, +) -> None: + database_url = f"sqlite:///{(tmp_path / 'enrollment.sqlite3').as_posix()}" + enrollment_token = "one-time-enrollment-token" + host_token = "host-token-" + ("x" * 40) + config = CloudControlConfig( + database_url=database_url, + credentials=( + BearerCredential( + principal_id="operator", + token="operator-token", + scopes=frozenset({"pool:read"}), + ), + ), + enrollment_credentials=( + EnrollmentCredential( + principal_id="installer-a", + token=enrollment_token, + ), + ), + ) + enrollment_payload = { + "agent_instance_id": "agent-instance-a", + "host_token": host_token, + "display_name": "Edge Mac", + } + + app = create_app(config=config) + with TestClient(app) as client: + enrolled = client.post( + "/internal/v1/enrollments", + headers={"Authorization": f"Bearer {enrollment_token}"}, + json=enrollment_payload, + ) + assert enrolled.status_code == 201 + host_id = enrolled.json()["host_id"] + assert host_id.startswith("host-") + + retried = client.post( + "/internal/v1/enrollments", + headers={"Authorization": f"Bearer {enrollment_token}"}, + json=enrollment_payload, + ) + assert retried.status_code == 201 + assert retried.json()["host_id"] == host_id + + reused = client.post( + "/internal/v1/enrollments", + headers={"Authorization": f"Bearer {enrollment_token}"}, + json={ + **enrollment_payload, + "agent_instance_id": "agent-instance-b", + }, + ) + assert reused.status_code == 409 + + device = client.post( + f"/internal/v1/hosts/{host_id}/devices/enroll", + headers={"Authorization": f"Bearer {host_token}"}, + json={ + "local_device_id": "local-device-a", + "driver_type": "wda", + "name": "iPhone", + "capability_tags": ["ios"], + }, + ) + assert device.status_code == 201 + device_id = device.json()["device_id"] + assert device_id.startswith("device-") + + device_retry = client.post( + f"/internal/v1/hosts/{host_id}/devices/enroll", + headers={"Authorization": f"Bearer {host_token}"}, + json={ + "local_device_id": "local-device-a", + "driver_type": "wda", + "name": "Renamed iPhone", + "capability_tags": ["ios", "physical"], + }, + ) + assert device_retry.json()["device_id"] == device_id + + rejected_snapshot = client.put( + f"/internal/v1/hosts/{host_id}/heartbeat", + headers={"Authorization": f"Bearer {host_token}"}, + json={ + "host_id": host_id, + "devices": [ + { + "device_id": "caller-selected-device", + "driver_type": "wda", + "status": "idle", + } + ], + }, + ) + assert rejected_snapshot.status_code == 409 + + heartbeat = client.put( + f"/internal/v1/hosts/{host_id}/heartbeat", + headers={"Authorization": f"Bearer {host_token}"}, + json={ + "host_id": host_id, + "devices": [ + { + "device_id": device_id, + "driver_type": "wda", + "status": "idle", + } + ], + }, + ) + assert heartbeat.status_code == 200 + + public_attempt = client.get( + "/v1/devices", + headers={"Authorization": f"Bearer {host_token}"}, + ) + assert public_attempt.status_code == 403 + + restarted = create_app(config=config) + with TestClient(restarted) as client: + heartbeat = client.put( + f"/internal/v1/hosts/{host_id}/heartbeat", + headers={"Authorization": f"Bearer {host_token}"}, + json={"host_id": host_id, "devices": []}, + ) + assert heartbeat.status_code == 200 + + assert ( + restarted.state.cloud_services.repository.authenticate_enrolled_host( + digest_token(host_token) + ) + == host_id + ) + assert restarted.state.cloud_services.repository.revoke_enrolled_host( + host_id, + revoked_at=utc_now(), + ) + rejected = client.put( + f"/internal/v1/hosts/{host_id}/heartbeat", + headers={"Authorization": f"Bearer {host_token}"}, + json={"host_id": host_id, "devices": []}, + ) + assert rejected.status_code == 401 def test_cloud_application_owns_database_lifecycle() -> None: diff --git a/apps/device-host-agent/host_agent/app.py b/apps/device-host-agent/host_agent/app.py index 5acff64..80ac75e 100644 --- a/apps/device-host-agent/host_agent/app.py +++ b/apps/device-host-agent/host_agent/app.py @@ -8,10 +8,12 @@ from cloud.internal_api.models import AssignmentModel from device.manager import DeviceManager from driver.registry import build_driver_factory from host_agent.assignment import AssignmentExecutor -from host_agent.client import HostAgentClient +from host_agent.client import HostAgentClient, HostAgentEnrollmentClient from host_agent.config import HostAgentConfig, load_host_agent_config +from host_agent.enrollment import resolve_host_identity from host_agent.execution import create_execution_factories from host_agent.heartbeat import HeartbeatSynchronizer +from host_agent.identity import HostIdentityStore from host_agent.lease import ActiveAssignmentRunner from host_agent.processor import AssignmentProcessingResult, AssignmentProcessor from storage.device_config import DeviceConfigStore @@ -97,9 +99,29 @@ def create_application( config: HostAgentConfig | None = None, manager: DeviceManager | None = None, device_config_store: DeviceConfigStore | None = None, + identity_store: HostIdentityStore | None = None, + enrollment_client: HostAgentEnrollmentClient | None = None, ) -> HostAgentApplication: - resolved_config = config or load_host_agent_config() - resolved_manager = manager or _configured_device_manager(device_config_store) + startup_config = config or load_host_agent_config() + config_store = device_config_store or DeviceConfigStore() + owned_enrollment_client = enrollment_client is None + bootstrap_client = enrollment_client or HostAgentEnrollmentClient(startup_config) + try: + resolved_config = resolve_host_identity( + startup_config, + identity_store=identity_store + or HostIdentityStore(startup_config.identity_path), + client=bootstrap_client, + ) + bootstrap_client.config = resolved_config + resolved_manager = manager or _configured_device_manager( + config_store, + config=resolved_config, + enrollment_client=bootstrap_client, + ) + finally: + if owned_enrollment_client: + bootstrap_client.close() client = HostAgentClient(resolved_config) heartbeat = HeartbeatSynchronizer(resolved_manager, client, resolved_config) executor = AssignmentExecutor(create_execution_factories(resolved_manager)) @@ -112,13 +134,25 @@ def create_application( def _configured_device_manager( - config_store: DeviceConfigStore | None, + config_store: DeviceConfigStore, + *, + config: HostAgentConfig, + enrollment_client: HostAgentEnrollmentClient, ) -> DeviceManager: manager = DeviceManager() - store = config_store or DeviceConfigStore() - for device in store.list(): + for device in config_store.list(): + runtime_device_id = device["device_id"] + if config.enrollment_managed: + enrollment = enrollment_client.enroll_device( + local_device_id=device["device_id"], + driver_type=device["driver_type"], + name=device["name"], + capability_tags=[], + ) + runtime_device_id = enrollment.device_id + config_store.set_cloud_device_id(device["device_id"], runtime_device_id) manager.register_device( - device["device_id"], + runtime_device_id, build_driver_factory( device["driver_type"], device["connection_info"], diff --git a/apps/device-host-agent/host_agent/client.py b/apps/device-host-agent/host_agent/client.py index 221baa1..97430eb 100644 --- a/apps/device-host-agent/host_agent/client.py +++ b/apps/device-host-agent/host_agent/client.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import time from collections.abc import Awaitable, Callable from typing import Any @@ -9,8 +10,10 @@ import httpx from cloud.internal_api.models import ( AssignmentModel, ClaimResponse, + DeviceEnrollmentResponse, DeviceSnapshotModel, HeartbeatResponse, + HostEnrollmentResponse, LeaseRenewalResponse, TerminalResultResponse, ) @@ -30,6 +33,99 @@ class StaleLeaseError(HostAgentAPIError): pass +class HostAgentEnrollmentClient: + def __init__( + self, + config: HostAgentConfig, + *, + http_client: httpx.Client | None = None, + sleep: Callable[[float], None] = time.sleep, + ) -> None: + self.config = config + self._sleep = sleep + self._owns_client = http_client is None + self._client = http_client or httpx.Client(base_url=config.control_plane_url) + + def enroll_host( + self, + *, + agent_instance_id: str, + host_token: str, + display_name: str | None, + ) -> HostEnrollmentResponse: + if not self.config.enrollment_token: + raise HostAgentAPIError(0, "Host enrollment token is unavailable") + response = self._request( + "POST", + "/internal/v1/enrollments", + token=self.config.enrollment_token, + json={ + "agent_instance_id": agent_instance_id, + "host_token": host_token, + "display_name": display_name, + }, + ) + return HostEnrollmentResponse.model_validate(response.json()) + + def enroll_device( + self, + *, + local_device_id: str, + driver_type: str, + name: str | None, + capability_tags: list[str], + ) -> DeviceEnrollmentResponse: + if not self.config.host_id or not self.config.token: + raise HostAgentAPIError(0, "Host identity is unresolved") + response = self._request( + "POST", + f"/internal/v1/hosts/{self.config.host_id}/devices/enroll", + token=self.config.token, + json={ + "local_device_id": local_device_id, + "driver_type": driver_type, + "name": name, + "capability_tags": list(capability_tags), + }, + ) + return DeviceEnrollmentResponse.model_validate(response.json()) + + def close(self) -> None: + if self._owns_client: + self._client.close() + + def _request( + self, + method: str, + path: str, + *, + token: str, + json: dict[str, Any], + ) -> httpx.Response: + backoff = self.config.retry_backoff_seconds + for attempt in range(1, self.config.max_retry_attempts + 1): + try: + response = self._client.request( + method, + path, + json=json, + headers={"Authorization": f"Bearer {token}"}, + ) + except httpx.TransportError: + if attempt == self.config.max_retry_attempts: + raise + else: + if response.status_code < 500: + if response.is_success: + return response + _raise_api_error(response) + if attempt == self.config.max_retry_attempts: + _raise_api_error(response) + self._sleep(backoff) + backoff = min(backoff * 2, self.config.max_retry_backoff_seconds) + raise AssertionError("retry loop exited unexpectedly") + + class HostAgentClient: def __init__( self, @@ -149,22 +245,24 @@ class HostAgentClient: if response.status_code < 500: if response.is_success: return response - self._raise_api_error(response) + _raise_api_error(response, stale_lease=True) if attempt == self.config.max_retry_attempts: - self._raise_api_error(response) + _raise_api_error(response, stale_lease=True) await self._sleep(backoff) backoff = min(backoff * 2, self.config.max_retry_backoff_seconds) raise AssertionError("retry loop exited unexpectedly") - @staticmethod - def _raise_api_error(response: httpx.Response) -> None: - try: - payload = response.json() - except ValueError: - payload = {} - detail = payload.get("detail") or payload.get("code") or "request rejected" - error_type = ( - StaleLeaseError if response.status_code == 409 else HostAgentAPIError - ) - raise error_type(response.status_code, str(detail)) + +def _raise_api_error(response: httpx.Response, *, stale_lease: bool = False) -> None: + try: + payload = response.json() + except ValueError: + payload = {} + detail = payload.get("detail") or payload.get("code") or "request rejected" + error_type = ( + StaleLeaseError + if stale_lease and response.status_code == 409 + else HostAgentAPIError + ) + raise error_type(response.status_code, str(detail)) diff --git a/apps/device-host-agent/host_agent/config.py b/apps/device-host-agent/host_agent/config.py index d758f35..29a5e0c 100644 --- a/apps/device-host-agent/host_agent/config.py +++ b/apps/device-host-agent/host_agent/config.py @@ -2,7 +2,8 @@ from __future__ import annotations import os from collections.abc import Mapping -from dataclasses import dataclass +from dataclasses import dataclass, field +from pathlib import Path from urllib.parse import urlparse @@ -13,8 +14,12 @@ class HostAgentConfigurationError(ValueError): @dataclass(frozen=True) class HostAgentConfig: control_plane_url: str - host_id: str - token: str + host_id: str = "" + token: str = field(default="", repr=False) + enrollment_token: str = field(default="", repr=False) + identity_path: Path = Path("tasks/host_identity.json") + enrollment_managed: bool = False + display_name: str | None = None heartbeat_interval_seconds: float = 30.0 poll_timeout_seconds: float = 20.0 retry_backoff_seconds: float = 1.0 @@ -41,16 +46,29 @@ def load_host_agent_config( ) host_id = values.get("HOST_AGENT_HOST_ID", "").strip() - if not host_id: - raise HostAgentConfigurationError("HOST_AGENT_HOST_ID is required") token = values.get("HOST_AGENT_TOKEN", "").strip() - if not token: - raise HostAgentConfigurationError("HOST_AGENT_TOKEN is required") + if bool(host_id) != bool(token): + raise HostAgentConfigurationError( + "HOST_AGENT_HOST_ID and HOST_AGENT_TOKEN must be configured together" + ) + enrollment_token = values.get("HOST_AGENT_ENROLLMENT_TOKEN", "").strip() + identity_path = Path( + values.get("HOST_AGENT_IDENTITY_PATH", "tasks/host_identity.json").strip() + ) + if not host_id and not enrollment_token and not identity_path.is_file(): + raise HostAgentConfigurationError( + "explicit Host credentials, an enrollment token, or existing identity state " + "is required" + ) config = HostAgentConfig( control_plane_url=control_plane_url, host_id=host_id, token=token, + enrollment_token=enrollment_token, + identity_path=identity_path, + enrollment_managed=not bool(host_id), + display_name=values.get("HOST_AGENT_DISPLAY_NAME") or None, heartbeat_interval_seconds=_positive_float( values, "HOST_AGENT_HEARTBEAT_INTERVAL_SECONDS", diff --git a/apps/device-host-agent/host_agent/enrollment.py b/apps/device-host-agent/host_agent/enrollment.py new file mode 100644 index 0000000..5c4f12c --- /dev/null +++ b/apps/device-host-agent/host_agent/enrollment.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from dataclasses import replace + +from host_agent.client import HostAgentEnrollmentClient +from host_agent.config import HostAgentConfig, HostAgentConfigurationError +from host_agent.identity import HostIdentityStore + + +def resolve_host_identity( + config: HostAgentConfig, + *, + identity_store: HostIdentityStore, + client: HostAgentEnrollmentClient, +) -> HostAgentConfig: + if config.host_id and config.token: + return config + state = identity_store.load_or_create() + if state.host_id is None: + if not config.enrollment_token: + raise HostAgentConfigurationError( + "HOST_AGENT_ENROLLMENT_TOKEN is required to complete enrollment" + ) + response = client.enroll_host( + agent_instance_id=state.agent_instance_id, + host_token=state.token, + display_name=config.display_name, + ) + state = identity_store.complete(state, response.host_id) + return replace( + config, + host_id=state.host_id or "", + token=state.token, + enrollment_managed=True, + ) diff --git a/apps/device-host-agent/host_agent/identity.py b/apps/device-host-agent/host_agent/identity.py new file mode 100644 index 0000000..8d546bb --- /dev/null +++ b/apps/device-host-agent/host_agent/identity.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import json +import os +from dataclasses import dataclass, field, replace +from pathlib import Path +from secrets import token_urlsafe +from uuid import uuid4 + + +class HostIdentityStateError(RuntimeError): + """Raised when persisted Host enrollment identity is missing or invalid.""" + + +@dataclass(frozen=True) +class HostIdentityState: + agent_instance_id: str + token: str = field(repr=False) + host_id: str | None = None + + +class HostIdentityStore: + def __init__(self, path: str | Path) -> None: + self.path = Path(path) + + def load(self) -> HostIdentityState | None: + if not self.path.exists(): + return None + try: + payload = json.loads(self.path.read_text(encoding="utf-8")) + except (OSError, ValueError, json.JSONDecodeError) as exc: + raise HostIdentityStateError("Host identity state is unreadable") from exc + if not isinstance(payload, dict): + raise HostIdentityStateError("Host identity state must be an object") + agent_instance_id = payload.get("agent_instance_id") + token = payload.get("token") + host_id = payload.get("host_id") + if ( + not isinstance(agent_instance_id, str) + or not agent_instance_id + or not isinstance(token, str) + or len(token) < 32 + or (host_id is not None and (not isinstance(host_id, str) or not host_id)) + ): + raise HostIdentityStateError("Host identity state is invalid") + return HostIdentityState( + agent_instance_id=agent_instance_id, + token=token, + host_id=host_id, + ) + + def load_or_create(self) -> HostIdentityState: + existing = self.load() + if existing is not None: + return existing + state = HostIdentityState( + agent_instance_id=f"agent-{uuid4().hex}", + token=token_urlsafe(48), + ) + self._write(state) + return state + + def complete(self, state: HostIdentityState, host_id: str) -> HostIdentityState: + if not host_id: + raise ValueError("host_id must not be empty") + current = self.load() + if current is not None and ( + current.agent_instance_id != state.agent_instance_id + or current.token != state.token + ): + raise HostIdentityStateError("Host identity changed during enrollment") + completed = replace(state, host_id=host_id) + self._write(completed) + return completed + + def _write(self, state: HostIdentityState) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + temporary = self.path.with_name(f".{self.path.name}.{uuid4().hex}.tmp") + payload = { + "agent_instance_id": state.agent_instance_id, + "token": state.token, + "host_id": state.host_id, + } + try: + temporary.write_text( + json.dumps(payload, ensure_ascii=True, indent=2) + "\n", + encoding="utf-8", + ) + _restrict_permissions(temporary) + os.replace(temporary, self.path) + _restrict_permissions(self.path) + finally: + if temporary.exists(): + temporary.unlink() + + +def _restrict_permissions(path: Path) -> None: + try: + path.chmod(0o600) + except OSError: + return diff --git a/apps/device-host-agent/tests/test_app.py b/apps/device-host-agent/tests/test_app.py index 6be9c5d..4ce1b4f 100644 --- a/apps/device-host-agent/tests/test_app.py +++ b/apps/device-host-agent/tests/test_app.py @@ -4,10 +4,15 @@ import asyncio from contextlib import suppress from datetime import UTC, datetime, timedelta -from cloud.internal_api.models import AssignmentModel +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.config import HostAgentConfig +from host_agent.identity import HostIdentityStore from storage.device_config import DeviceConfigStore @@ -65,6 +70,106 @@ def test_create_application_loads_persisted_device_configuration( 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_token="one-time-token", + 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() diff --git a/apps/device-host-agent/tests/test_client.py b/apps/device-host-agent/tests/test_client.py index 78e167d..451367e 100644 --- a/apps/device-host-agent/tests/test_client.py +++ b/apps/device-host-agent/tests/test_client.py @@ -8,7 +8,11 @@ import httpx import pytest from cloud.internal_api.models import AssignmentModel, DeviceSnapshotModel -from host_agent.client import HostAgentClient, StaleLeaseError +from host_agent.client import ( + HostAgentClient, + HostAgentEnrollmentClient, + StaleLeaseError, +) from host_agent.config import HostAgentConfig @@ -166,3 +170,58 @@ def test_result_report_retries_identical_payload_after_response_loss() -> None: assert len(payloads) == 2 assert payloads[0] == payloads[1] assert payloads[0]["failure_reason"] == "planner unavailable" + + +def test_bootstrap_client_retries_identical_enrollment_and_enrolls_device() -> None: + requests: list[httpx.Request] = [] + host_attempts = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal host_attempts + requests.append(request) + if request.url.path == "/internal/v1/enrollments": + host_attempts += 1 + if host_attempts == 1: + raise httpx.ReadError("response lost", request=request) + return httpx.Response(201, json={"host_id": "host-cloud-a"}) + return httpx.Response(201, json={"device_id": "device-cloud-a"}) + + config = _config( + host_id="", + token="", + enrollment_token="one-time-token", + enrollment_managed=True, + ) + with httpx.Client( + transport=httpx.MockTransport(handler), + base_url="https://control.example", + ) as http_client: + client = HostAgentEnrollmentClient( + config, + http_client=http_client, + sleep=lambda _delay: None, + ) + host = client.enroll_host( + agent_instance_id="agent-instance-a", + host_token="host-token-" + ("x" * 40), + display_name="Edge Mac", + ) + client.config = _config( + host_id=host.host_id, + token="host-token-" + ("x" * 40), + enrollment_token="one-time-token", + enrollment_managed=True, + ) + device = client.enroll_device( + local_device_id="local-device-a", + driver_type="wda", + name="iPhone", + capability_tags=["ios"], + ) + + assert host.host_id == "host-cloud-a" + assert device.device_id == "device-cloud-a" + assert len(requests) == 3 + assert requests[0].content == requests[1].content + assert requests[0].headers["authorization"] == "Bearer one-time-token" + assert requests[2].headers["authorization"] == ("Bearer host-token-" + ("x" * 40)) diff --git a/apps/device-host-agent/tests/test_config.py b/apps/device-host-agent/tests/test_config.py index 582cfaf..4b4332e 100644 --- a/apps/device-host-agent/tests/test_config.py +++ b/apps/device-host-agent/tests/test_config.py @@ -1,5 +1,7 @@ from __future__ import annotations +from pathlib import Path + import pytest from host_agent.config import ( @@ -40,11 +42,44 @@ def test_load_host_agent_config_parses_poll_and_retry_values() -> None: assert config.max_retry_backoff_seconds == 20 +def test_load_host_agent_config_supports_managed_enrollment(tmp_path) -> None: + identity_path = tmp_path / "host_identity.json" + config = load_host_agent_config( + { + "HOST_AGENT_CONTROL_PLANE_URL": "https://cloud.example", + "HOST_AGENT_ENROLLMENT_TOKEN": "one-time-token", + "HOST_AGENT_IDENTITY_PATH": str(identity_path), + "HOST_AGENT_DISPLAY_NAME": "Edge Mac", + } + ) + + assert config.host_id == "" + assert config.token == "" + assert config.enrollment_token == "one-time-token" + assert config.identity_path == identity_path + assert config.enrollment_managed is True + assert config.display_name == "Edge Mac" + assert "one-time-token" not in repr(config) + + +def test_existing_identity_state_allows_restart_without_enrollment_token( + tmp_path, +) -> None: + identity_path = tmp_path / "host_identity.json" + identity_path.write_text("{}", encoding="utf-8") + + config = load_host_agent_config({"HOST_AGENT_IDENTITY_PATH": str(identity_path)}) + + assert config.identity_path == Path(identity_path) + assert config.enrollment_managed is True + + @pytest.mark.parametrize( "overrides", [ {"HOST_AGENT_HOST_ID": ""}, {"HOST_AGENT_TOKEN": ""}, + {"HOST_AGENT_HOST_ID": "host-a", "HOST_AGENT_TOKEN": ""}, {"HOST_AGENT_CONTROL_PLANE_URL": "ftp://cloud.example"}, {"HOST_AGENT_POLL_TIMEOUT_SECONDS": "0"}, { diff --git a/apps/device-host-agent/tests/test_identity.py b/apps/device-host-agent/tests/test_identity.py new file mode 100644 index 0000000..335c829 --- /dev/null +++ b/apps/device-host-agent/tests/test_identity.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import os + +import pytest + +from host_agent.identity import ( + HostIdentityStateError, + HostIdentityStore, +) + + +def test_identity_store_persists_pending_and_completed_state(tmp_path) -> None: + path = tmp_path / "state" / "host_identity.json" + store = HostIdentityStore(path) + + pending = store.load_or_create() + assert pending.host_id is None + assert len(pending.token) >= 32 + assert "token=" not in repr(pending) + assert store.load() == pending + + completed = store.complete(pending, "host-cloud-a") + assert completed.host_id == "host-cloud-a" + assert store.load() == completed + assert "host-cloud-a" in path.read_text(encoding="utf-8") + if os.name != "nt": + assert path.stat().st_mode & 0o777 == 0o600 + + +def test_identity_store_rejects_invalid_or_changed_state(tmp_path) -> None: + path = tmp_path / "host_identity.json" + path.write_text('{"agent_instance_id":"a"}', encoding="utf-8") + store = HostIdentityStore(path) + + with pytest.raises(HostIdentityStateError): + store.load() + + path.unlink() + pending = store.load_or_create() + path.write_text( + '{"agent_instance_id":"other","token":"' + ("x" * 40) + '"}', + encoding="utf-8", + ) + with pytest.raises(HostIdentityStateError, match="changed"): + store.complete(pending, "host-a") diff --git a/compose.deploy.yaml b/compose.deploy.yaml index ebbb1de..e3ecb7c 100644 --- a/compose.deploy.yaml +++ b/compose.deploy.yaml @@ -27,6 +27,7 @@ services: CLOUD_DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB} CLOUD_PUBLIC_CREDENTIALS_JSON: ${CLOUD_PUBLIC_CREDENTIALS_JSON} CLOUD_HOST_CREDENTIALS_JSON: ${CLOUD_HOST_CREDENTIALS_JSON} + CLOUD_ENROLLMENT_TOKENS_JSON: ${CLOUD_ENROLLMENT_TOKENS_JSON:-[]} CLOUD_SCHEDULER_INTERVAL_SECONDS: ${CLOUD_SCHEDULER_INTERVAL_SECONDS:-1} CLOUD_LEASE_REAPER_INTERVAL_SECONDS: ${CLOUD_LEASE_REAPER_INTERVAL_SECONDS:-5} CLOUD_LEASE_DURATION_SECONDS: ${CLOUD_LEASE_DURATION_SECONDS:-60} @@ -50,6 +51,9 @@ services: HOST_AGENT_CONTROL_PLANE_URL: http://cloud-api:8001 HOST_AGENT_HOST_ID: ${HOST_AGENT_HOST_ID} HOST_AGENT_TOKEN: ${HOST_AGENT_TOKEN} + HOST_AGENT_ENROLLMENT_TOKEN: ${HOST_AGENT_ENROLLMENT_TOKEN:-} + HOST_AGENT_IDENTITY_PATH: ${HOST_AGENT_IDENTITY_PATH:-/app/tasks/host_identity.json} + HOST_AGENT_DISPLAY_NAME: ${HOST_AGENT_DISPLAY_NAME:-} HOST_AGENT_HEARTBEAT_INTERVAL_SECONDS: ${HOST_AGENT_HEARTBEAT_INTERVAL_SECONDS:-30} HOST_AGENT_POLL_TIMEOUT_SECONDS: ${HOST_AGENT_POLL_TIMEOUT_SECONDS:-20} HOST_AGENT_RETRY_BACKOFF_SECONDS: ${HOST_AGENT_RETRY_BACKOFF_SECONDS:-1} diff --git a/compose.yaml b/compose.yaml index 0c52c2b..00e77de 100644 --- a/compose.yaml +++ b/compose.yaml @@ -28,6 +28,7 @@ services: CLOUD_DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB} CLOUD_PUBLIC_CREDENTIALS_JSON: ${CLOUD_PUBLIC_CREDENTIALS_JSON} CLOUD_HOST_CREDENTIALS_JSON: ${CLOUD_HOST_CREDENTIALS_JSON} + CLOUD_ENROLLMENT_TOKENS_JSON: ${CLOUD_ENROLLMENT_TOKENS_JSON:-[]} CLOUD_SCHEDULER_INTERVAL_SECONDS: ${CLOUD_SCHEDULER_INTERVAL_SECONDS:-1} CLOUD_LEASE_REAPER_INTERVAL_SECONDS: ${CLOUD_LEASE_REAPER_INTERVAL_SECONDS:-5} CLOUD_LEASE_DURATION_SECONDS: ${CLOUD_LEASE_DURATION_SECONDS:-60} @@ -52,6 +53,9 @@ services: HOST_AGENT_CONTROL_PLANE_URL: http://cloud-api:8001 HOST_AGENT_HOST_ID: ${HOST_AGENT_HOST_ID} HOST_AGENT_TOKEN: ${HOST_AGENT_TOKEN} + HOST_AGENT_ENROLLMENT_TOKEN: ${HOST_AGENT_ENROLLMENT_TOKEN:-} + HOST_AGENT_IDENTITY_PATH: ${HOST_AGENT_IDENTITY_PATH:-/app/tasks/host_identity.json} + HOST_AGENT_DISPLAY_NAME: ${HOST_AGENT_DISPLAY_NAME:-} HOST_AGENT_HEARTBEAT_INTERVAL_SECONDS: ${HOST_AGENT_HEARTBEAT_INTERVAL_SECONDS:-30} HOST_AGENT_POLL_TIMEOUT_SECONDS: ${HOST_AGENT_POLL_TIMEOUT_SECONDS:-20} HOST_AGENT_RETRY_BACKOFF_SECONDS: ${HOST_AGENT_RETRY_BACKOFF_SECONDS:-1} diff --git a/docs/CLOUD_DEPLOYMENT.md b/docs/CLOUD_DEPLOYMENT.md index 53e9e38..9562b80 100644 --- a/docs/CLOUD_DEPLOYMENT.md +++ b/docs/CLOUD_DEPLOYMENT.md @@ -41,6 +41,49 @@ defaults to `./tasks`. The Host Agent only initiates outbound HTTP requests. It does not expose an inbound port. +## Managed Edge Enrollment + +New edge installations do not need a pre-coordinated Host or device ID. The +Cloud API accepts configured one-time enrollment credentials: + +```powershell +$env:CLOUD_ENROLLMENT_TOKENS_JSON = '[{"principal_id":"edge-installer","token":"replace-with-a-long-random-one-time-token"}]' +``` + +On the edge Host, omit `HOST_AGENT_HOST_ID` and `HOST_AGENT_TOKEN` and provide +the enrollment token only for the first successful enrollment: + +```bash +export HOST_AGENT_CONTROL_PLANE_URL="https://cloud.example.com" +export HOST_AGENT_ENROLLMENT_TOKEN="replace-with-a-long-random-one-time-token" +export HOST_AGENT_IDENTITY_PATH="tasks/host_identity.json" +export HOST_AGENT_DISPLAY_NAME="Edge Mac 01" +uv run --package device-host-agent device-host-agent +``` + +Before its first request the Host Agent creates `HOST_AGENT_IDENTITY_PATH` with +an instance identifier and long-lived random Host secret. The cloud consumes +the enrollment token, assigns `host_id`, stores only credential digests, and +returns the assigned ID. The Host Agent then enrolls each record from +`tasks/device_config.sqlite3`, stores its cloud-assigned `device_id` in that +database, connects the resulting devices, and starts heartbeat/claim loops. + +Keep the identity file and device configuration database on persistent edge +storage with permissions limited to the service account. The identity file is +a bearer secret: do not put it in an image, repository, log, or general backup. +After successful enrollment, remove `HOST_AGENT_ENROLLMENT_TOKEN` from the edge +environment. An intact identity file is sufficient for restart; if only a +device mapping is lost, device enrollment reconstructs the same cloud ID. + +Enrollment tokens are one-time even when they remain in Cloud API environment +configuration: their consumed digest is stored in the database. Reusing a token +for another edge instance returns a conflict. Create a distinct token for every +edge installation. + +Explicit `HOST_AGENT_HOST_ID` plus `HOST_AGENT_TOKEN` takes precedence and keeps +the previous legacy behavior, including locally selected device IDs. This is +the rollback and staged-migration path for existing deployments. + ## PostgreSQL Deployment Start from `.env.example`, replace every `change-me-*` value, and keep the @@ -106,11 +149,40 @@ only the scopes required by each integration: include exactly one `host_id`; its token is valid only for heartbeat, claim, renewal, and result operations for that host. +`CLOUD_ENROLLMENT_TOKENS_JSON` contains bootstrap principals with only +`principal_id` and `token`. These credentials cannot submit tasks, read the +pool, or operate as a Host; they can only create one durable Host binding. +Use high-entropy values generated by the deployment secret manager. + Do not place bearer tokens in command history, image layers, Compose files, or logs. Use environment injection or the deployment platform's secret manager. Rotate a token by deploying the updated Cloud API credential set and Host Agent configuration together. +Dynamically enrolled Host credentials are stored as digests in the cloud +database. This release exposes repository-level revocation rather than a public +administration endpoint. An operator with database deployment access can revoke +a Host without deleting its task history: + +```bash +export HOST_ID="host-..." +uv run --package device-cloud-platform python - <<'PY' +import os +from cloud.database import CloudDatabase +from core.models import utc_now + +database = CloudDatabase(os.environ["CLOUD_DATABASE_URL"], create_schema=False) +try: + changed = database.repository.revoke_enrolled_host( + os.environ["HOST_ID"], + revoked_at=utc_now(), + ) + print("revoked" if changed else "not an enrolled host") +finally: + database.close() +PY +``` + ## Runtime AI Planner The Host Agent reuses the local Runtime planner. AI planning is disabled by @@ -167,17 +239,24 @@ For rollback: 1. Stop all Host Agents and the Cloud API. 2. Back up PostgreSQL or the SQLite database file. -3. If the previous application version cannot use the current schema, run the +3. Before rolling back to a release without enrollment support, provision + temporary static Host credentials for every managed edge that must continue + operating. Stop those Host Agents and set their explicit Host ID/token. +4. If the previous application version cannot use the current schema, run the tested downgrade while no application process is connected: ```bash uv run alembic -c packages/cloud-platform/cloud/migrations/alembic.ini downgrade -1 ``` -4. Restore the previous application image or checkout and start the Cloud API. -5. Verify `/health/ready`, then restart Host Agents with credentials compatible +5. Restore the previous application image or checkout and start the Cloud API. +6. Verify `/health/ready`, then restart Host Agents with credentials compatible with the restored Cloud API. +Downgrading revision 0002 removes dynamic credential bindings and durable +device enrollment mappings. It retains the revision-0001 Host heartbeat rows, +pooled devices, queued tasks, attempts, and plugins. + Do not remove the PostgreSQL volume during an application rollback. Queued and attempt history are durable database state and should remain available to the restored or forward-deployed control plane. diff --git a/docs/MACOS_IPHONE_SETUP.md b/docs/MACOS_IPHONE_SETUP.md index 77fe0f3..6e3f58f 100644 --- a/docs/MACOS_IPHONE_SETUP.md +++ b/docs/MACOS_IPHONE_SETUP.md @@ -338,7 +338,60 @@ Console 默认连接 `http://127.0.0.1:8000`。已由上面启动脚本连接的 `iphone-1` 会出现在设备列表中。不要在 Console 中重复登记同一台设备;当前登记 操作只写入配置,不会自动 connect。 -## 9. 多设备与端口 +## 9. 启动云端受管 Host Agent + +完成 Appium/WDA 真机验证后,可以把这台 Mac 作为只发起出站连接的边缘 Host。 +先把设备连接参数写入本地配置;这里的 `device_id` 只是 Mac 内部引用,不需要与 +云端协调,云端会在首次启动时下发正式 ID: + +```bash +uv run --package device-agent-runtime python - <<'PY' +import os +from storage.device_config import DeviceConfigStore + +DeviceConfigStore("tasks/device_config.sqlite3").add( + device_id="local-iphone-1", + name="Edge iPhone", + driver_type="wda", + connection_info={ + "server_url": "http://127.0.0.1:4723", + "device_name": "iPhone", + "udid": os.environ["DEVICE_UDID"], + "wda_local_port": 8100, + "xcodeOrgId": os.environ["APPLE_TEAM_ID"], + "xcodeSigningId": "Apple Development", + "updatedWDABundleId": os.environ["WDA_BUNDLE_ID"], + }, +) +PY +``` + +从云端管理员获取一枚未使用的一次性 enrollment token,然后启动: + +```bash +export HOST_AGENT_CONTROL_PLANE_URL="https://cloud.example.com" +export HOST_AGENT_ENROLLMENT_TOKEN="" +export HOST_AGENT_IDENTITY_PATH="tasks/host_identity.json" +export HOST_AGENT_DISPLAY_NAME="Edge Mac 01" + +export AI_PLANNER_ENABLED="true" +export AI_PLANNER_PROVIDER="anthropic" +export ANTHROPIC_API_KEY="" + +uv run --package device-host-agent device-host-agent +``` + +首次启动顺序为:持久化候选 Host secret、向云端换取 `host_id`、为每个本地设备 +换取 `device_id`、保存映射、连接 WDA、发送 heartbeat、开始 long-poll 领取任务。 +Host Agent 不监听入站端口。成功后可以从边缘环境移除 enrollment token,但必须 +保留并保护 `tasks/host_identity.json` 和 `tasks/device_config.sqlite3`;前者等同于 +Host bearer credential。 + +需要回滚到静态模式时,停止 Host Agent,由云端管理员配置匹配的静态 Host +credential,然后显式设置 `HOST_AGENT_HOST_ID` 与 `HOST_AGENT_TOKEN`。这两个变量 +同时存在时优先于 enrollment state。 + +## 10. 多设备与端口 同时连接多台 iPhone 时,每台设备至少需要: @@ -364,7 +417,7 @@ Console 默认连接 `http://127.0.0.1:8000`。已由上面启动脚本连接的 `webDriverAgentUrl`。常规单机使用优先让 Appium 管理 WDA,不要一开始就引入 `iproxy` 或手工 WDA 生命周期。 -## 10. 常见故障 +## 11. 常见故障 ### Appium 返回 `device offline` @@ -420,7 +473,7 @@ Appium server 默认使用 4723;WDA 通常使用 8100。多设备必须为每 input、launch 和 UI tree 验证基础控制,再单独处理 PaddleOCR/PaddlePaddle 的 macOS wheel 与 Apple Silicon 兼容性。 -## 11. 完成检查表 +## 12. 完成检查表 - [ ] Xcode 能看到已解锁的 iPhone。 - [ ] iPhone 已信任 Mac,并启用 Developer Mode。 @@ -433,8 +486,9 @@ wheel 与 Apple Silicon 兼容性。 - [ ] 实机 integration test 通过。 - [ ] Runtime API 返回 `iphone-1`,并能执行 screenshot/tap/launch。 - [ ] 如需 OCR,另行确认 PaddleOCR 在当前 Mac/Python 架构下可运行。 +- [ ] 云端受管部署已保存 Host identity,并能在 `/v1/hosts`、`/v1/devices` 中看到。 -## 12. 后续代码改进建议 +## 13. 后续代码改进建议 为了让后续执行不再依赖内联 Python 启动脚本,建议另开变更实现: diff --git a/openspec/changes/edge-host-enrollment/.openspec.yaml b/openspec/changes/edge-host-enrollment/.openspec.yaml new file mode 100644 index 0000000..b119b63 --- /dev/null +++ b/openspec/changes/edge-host-enrollment/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-13 diff --git a/openspec/changes/edge-host-enrollment/design.md b/openspec/changes/edge-host-enrollment/design.md new file mode 100644 index 0000000..7cba7da --- /dev/null +++ b/openspec/changes/edge-host-enrollment/design.md @@ -0,0 +1,111 @@ +## Context + +The current Cloud Control Plane authenticates Host Agents from a static environment-provided credential list. A Host Agent must start with a pre-agreed `host_id`, bearer token, and locally chosen device IDs; its first heartbeat implicitly creates the host and pooled-device rows. This works for controlled development but creates manual coordination, identity collisions, and unsafe retry behavior for repeatable edge deployment. + +The change crosses Cloud API authentication, durable repository state, migrations, internal protocol models, Host Agent startup, and local device configuration. It must preserve the established outbound-only Host Agent protocol, PostgreSQL/SQLite parity, the existing static-credential deployment path, and the Runtime dependency direction: enrollment remains an outer cloud/application concern and does not enter `core`, `driver`, `device`, or `tools`. + +## Goals / Non-Goals + +**Goals:** + +- Allow a new edge installation to start with a control-plane URL and one-time enrollment token instead of pre-coordinated Host and device IDs. +- Make the Cloud Control Plane authoritative for generated `host_id` and `device_id` values. +- Keep enrollment retries idempotent across response loss and process restart without persisting plaintext long-lived Host secrets in cloud storage. +- Persist Host credentials and device enrollment mappings through the existing repository abstraction for both PostgreSQL and SQLite. +- Let dynamically enrolled Host Agents authenticate normal heartbeat, claim, renew, and result requests through the existing Host-bound authorization contract. +- Preserve explicit `HOST_AGENT_HOST_ID` and `HOST_AGENT_TOKEN` configuration as a compatible legacy mode. +- Protect existing local Runtime device IDs by storing the cloud mapping separately and translating managed assignments at Host Agent composition time. + +**Non-Goals:** + +- Automatic discovery of iPhones from USB/Appium; operators still create local device configuration records. +- A public enrollment-management UI, tenant model, certificate authority, mTLS, or remote Host installation service. +- Silent transfer of an enrolled device between Hosts. Moving a phone creates a new Host-scoped device enrollment unless a future explicit transfer capability is added. +- Distribution of Appium/WDA signing configuration, LLM credentials, or workflow definitions from the cloud. +- Removal of static Host credentials in this change. + +## Decisions + +### D1. Separate bootstrap enrollment from the operational Host protocol + +Add `POST /internal/v1/enrollments` authenticated by a configured enrollment token. The endpoint is the only internal route that does not require an existing Host-bound principal. Heartbeat, claim, renewal, result reporting, and device enrollment continue to require a Host-bound bearer credential. + +Alternative considered: allow an unknown Host to create itself through heartbeat. Rejected because heartbeat would mix bootstrap authentication, identity creation, and state replacement, and an interrupted first heartbeat would be difficult to distinguish from credential misuse. + +### D2. The edge generates the long-lived Host secret; the cloud generates the Host ID + +Before its first enrollment request, the Host Agent generates and durably stores an `agent_instance_id` and a high-entropy bearer token. The enrollment request sends the candidate Host token over TLS while authenticating with the one-time enrollment token. The cloud generates `host_id`, stores only the Host token digest, and binds it to the instance. + +This makes response-loss retries safe: the same instance can resend the same candidate secret, and the server can verify its digest and return the same `host_id`. The cloud never needs to retain or re-return plaintext Host credentials. + +Alternative considered: cloud-generated Host secret returned once. Rejected because a lost response would require either unrecoverable enrollment or plaintext/encrypted secret recovery state on the server. + +### D3. Enrollment tokens are configured but consumption is durable + +`CLOUD_ENROLLMENT_TOKENS_JSON` supplies high-entropy bootstrap tokens to the Cloud API. Authentication compares token digests without logging token values. The Host enrollment transaction records the enrollment-token digest used by an instance, with a uniqueness constraint so a token cannot enroll a second instance after restart. + +An identical retry for the same `agent_instance_id` and Host credential digest returns the existing Host identity. Reuse for another instance, or retry with a different candidate Host token, returns a conflict. + +### D4. Dynamic Host credentials compose with existing configured credentials + +Add a repository-backed Host `AuthProvider` that hashes the presented bearer token and resolves a non-revoked enrolled Host. Compose it after the existing configured bearer provider. Public SDK scopes remain configuration-driven; dynamically enrolled credentials receive only a Host-bound principal and therefore cannot call public operator APIs. + +Static Host credentials retain current behavior and local device IDs. This limits migration risk and permits staged deployment. + +### D5. Durable device enrollment is separate from transient pool state + +Add a `device_enrollments` table with cloud-generated `device_id`, owning `host_id`, opaque `local_device_id`, driver metadata, enrollment timestamps, and revocation state. Enforce uniqueness for both `device_id` and `(host_id, local_device_id)`. + +`POST /internal/v1/hosts/{host_id}/devices/enroll` is Host-authenticated. Repeating the same Host/local-device pair returns the same cloud ID and may refresh non-identity metadata. A different Host receives a different ID even if it reports the same physical phone. + +Pooled-device rows remain replaceable heartbeat projections. For enrollment-managed Hosts, a heartbeat may report only non-revoked device IDs enrolled to that Host, with matching driver type. Legacy statically authenticated Hosts retain the existing snapshot behavior. + +### D6. Local Runtime identity and cloud identity remain distinct + +Extend `DeviceConfigStore` records with nullable `cloud_device_id`. Existing `device_id` remains the local Runtime/configuration key and is used as the opaque enrollment reference; no raw iPhone UDID must be sent solely for enrollment. + +In managed mode the Host Agent enrolls every configured device before constructing its `DeviceManager`, persists returned mappings, and registers drivers under the cloud `device_id`. Assignment execution therefore continues to use the existing `DeviceManager` and tool contracts without adding translation logic to Runtime layers. Legacy mode registers the existing local IDs unchanged. + +### D7. Host identity state is written before and after network enrollment + +Use an edge-local identity file under the mounted tasks/state path. Before the first request, atomically persist the generated instance ID and Host token; after a successful response, atomically add the assigned Host ID. Restrict file permissions to the current user where the operating system supports it. Environment-provided explicit Host credentials take precedence and do not overwrite managed identity state. + +This state is a secret and must be backed up or deliberately revoked before replacement. Losing it causes a new enrollment rather than unsafe guessing of a prior identity. + +### D8. Schema revision 0002 carries enrollment state + +Add a forward/downgrade Alembic revision after `0001_cloud_repository`. Extend `host_registrations` with nullable instance, credential, enrollment-token, display-name, enrolled-at, and revoked-at fields, plus required uniqueness/indexes. Add `device_enrollments`. Fresh local/test databases continue to use SQLAlchemy metadata creation; production readiness requires revision 0002. + +The repository owns atomic Host enrollment, credential lookup/revocation, device enrollment/lookup, and managed-snapshot validation queries. HTTP handlers do not assemble multi-step uniqueness checks outside the transaction. + +## Risks / Trade-offs + +- [Identity file theft permits Host impersonation] -> Store only on the edge host, set restrictive permissions, keep it outside images/source control, use HTTPS, and support repository-level revocation. +- [Configured enrollment tokens remain present after consumption] -> Persist token-digest consumption with a unique constraint so application restart or unchanged environment configuration cannot reuse them. +- [Static and managed modes increase transitional complexity] -> Make the mode explicit in resolved Host Agent configuration and cover both paths with contract tests; do not silently convert a static deployment. +- [Device mapping is lost locally] -> Re-enrollment is idempotent by `(host_id, local_device_id)` and reconstructs the same cloud ID when Host identity state remains available. +- [Phone movement creates multiple historical device IDs] -> Treat enrollment as a Host attachment for this release; require future explicit transfer semantics before preserving identity across Hosts. +- [Database-backed auth adds a query to Host requests] -> Query by an indexed SHA-256 digest. Optimize with bounded caching only after measurement; revocation correctness takes priority. +- [Rollback cannot authenticate newly enrolled Hosts] -> Keep static credential support and require operators to provision temporary static credentials before rolling back application/schema. + +## Migration Plan + +1. Deploy the schema migration while existing Cloud API and Host Agent versions are stopped or compatible with the additive schema. +2. Deploy the Cloud API with `CLOUD_ENROLLMENT_TOKENS_JSON`; retain existing `CLOUD_HOST_CREDENTIALS_JSON` during migration. +3. Verify readiness and enrollment API tests, then deploy new Host Agents. +4. Existing explicitly configured Host Agents continue in legacy mode. New edge installations use enrollment mode and persist identity/device mappings under their tasks/state volume. +5. After all managed Hosts are verified, rotate or remove no-longer-required static Host credentials independently; public SDK credentials remain configured. + +Rollback requires stopping managed Host Agents, provisioning static Host credentials and IDs for any Host that must continue operating on the previous release, then downgrading the schema to revision 0001. The downgrade removes dynamic credentials and durable device enrollments but leaves legacy hosts, pooled devices, tasks, attempts, and plugins intact. + +## Open Questions + +No blocking questions remain for the first implementation. Certificate-based Host identity, enrollment-token administration APIs, and cross-Host device transfer are intentionally deferred. + +## Verification Notes + +- Repository, migration, authentication, Cloud API, Host Agent, deployment-contract, and existing end-to-end tests pass in the local SQLite/non-integration environment. +- PostgreSQL behavior is covered by the shared parameterized repository contract but was not executed without `TEST_POSTGRES_URL`. +- No real macOS/iPhone/Appium environment was available to validate first enrollment against physical hardware. +- Host revocation is implemented at the repository/operations layer; a public administrative revocation API and UI remain out of scope. +- Device discovery remains explicit local configuration, and device identity transfer between Hosts remains intentionally unsupported. diff --git a/openspec/changes/edge-host-enrollment/proposal.md b/openspec/changes/edge-host-enrollment/proposal.md new file mode 100644 index 0000000..a2a5211 --- /dev/null +++ b/openspec/changes/edge-host-enrollment/proposal.md @@ -0,0 +1,31 @@ +## Why + +Deploying a Device Host Agent currently requires an operator to pre-coordinate both `host_id` and every `device_id` with the Cloud Control Plane. This makes edge installation brittle and prevents a cloud-authoritative onboarding flow where a trusted control plane assigns stable identities after a host proves possession of a bootstrap credential. + +## What Changes + +- Add an authenticated Host enrollment operation that consumes a configured one-time enrollment token and returns a cloud-generated `host_id`. +- Let the enrolling Host Agent generate its long-lived bearer secret locally, so retries remain idempotent without the control plane storing or returning plaintext credentials. +- Persist dynamically enrolled host credential digests in the cloud repository while retaining existing statically configured Host credentials for compatibility. +- Add an authenticated device enrollment operation that maps a host-scoped opaque local device reference to a cloud-generated `device_id`. +- Persist the cloud device mapping separately from transient heartbeat state and require heartbeat snapshots to use device IDs assigned to the authenticated host. +- Add Host Agent bootstrap and identity persistence so an edge installation can start with only the control-plane URL and an enrollment token, recover safely after response loss, and reuse assigned IDs after restart. +- Extend local device configuration with an optional cloud device mapping while keeping existing local Runtime device identifiers compatible. +- Update deployment configuration and operator documentation for enrollment-token provisioning, identity-state protection, migration, revocation, and static-credential fallback. + +## Capabilities + +### New Capabilities +- `edge-host-enrollment`: Secure, idempotent Host and device onboarding with cloud-assigned identities and edge-persisted credential/mapping state. + +### Modified Capabilities +- `host-agent-protocol`: Permit bootstrap enrollment before the normal authenticated heartbeat/claim protocol and require enrolled cloud device IDs in later Host Agent traffic. +- `device-pool`: Separate durable device enrollment identity from transient heartbeat state and accept only device identities assigned to the reporting host. +- `cloud-control-plane`: Persist dynamic Host credentials and enrollment records through the shared PostgreSQL/SQLite repository and schema migration contract. + +## Impact + +- Cloud authentication, internal Host Agent API models/routes, repository protocol, SQLAlchemy models, migrations, and application composition. +- Device Host Agent configuration, startup/bootstrap client, local identity persistence, device configuration mapping, heartbeat construction, and assignment execution lookup. +- Environment variables, Compose wiring, deployment documentation, repository/HTTP/Host Agent tests, migration tests, and OpenSpec main capability contracts after archive. +- Existing deployments using `CLOUD_HOST_CREDENTIALS_JSON` with explicit `HOST_AGENT_HOST_ID` and `HOST_AGENT_TOKEN` remain supported during migration. diff --git a/openspec/changes/edge-host-enrollment/specs/cloud-control-plane/spec.md b/openspec/changes/edge-host-enrollment/specs/cloud-control-plane/spec.md new file mode 100644 index 0000000..5ceadc6 --- /dev/null +++ b/openspec/changes/edge-host-enrollment/specs/cloud-control-plane/spec.md @@ -0,0 +1,44 @@ +## MODIFIED Requirements + +### Requirement: Deployment and local persistence modes share one contract +The cloud repository SHALL support PostgreSQL for deployed operation and SQLite for local development and tests through the same behavioral contract, including hosts, dynamic Host credential bindings, device enrollments, pooled devices, tasks, leases, attempts, and plugins. + +#### Scenario: Start with PostgreSQL +- **WHEN** the configured database URL selects PostgreSQL and the schema is current +- **THEN** the control plane uses PostgreSQL for cloud state, enrollment idempotency, authentication lookup, and transactional assignment operations + +#### Scenario: Start in local SQLite mode +- **WHEN** the configured database URL selects SQLite in a local or test environment +- **THEN** the same repository contract, including Host and device enrollment, is available with the documented single-control-plane concurrency limitation + +### Requirement: Cloud schema is versioned with migrations +The system SHALL provide versioned forward and downgrade database migrations and SHALL refuse readiness when the database schema is incompatible with the running application. + +#### Scenario: Upgrade an existing cloud database +- **WHEN** an operator applies the enrollment release migration to a database at revision 0001 +- **THEN** existing hosts, pooled devices, tasks, attempts, and plugins are retained while nullable Host enrollment fields and durable device enrollment storage are added + +#### Scenario: Downgrade the enrollment schema +- **WHEN** an operator downgrades revision 0002 while no enrollment-capable application process is connected +- **THEN** dynamic Host credential and device enrollment storage is removed while legacy cloud state from revision 0001 remains available + +#### Scenario: Schema is behind at startup +- **WHEN** the application connects to a database whose schema version is not accepted by the running release +- **THEN** readiness fails with a diagnostic that does not expose credentials + +## ADDED Requirements + +### Requirement: Dynamic Host authentication uses durable credential digests +The Cloud Control Plane SHALL authenticate dynamically enrolled Host bearer credentials through indexed repository lookup of a cryptographic token digest and SHALL compose that lookup with existing configured credentials. + +#### Scenario: Enrolled Host authenticates after Cloud API restart +- **WHEN** a non-revoked enrolled Host presents its bearer credential after the Cloud API restarts +- **THEN** the repository-backed authentication provider resolves the stored Host binding and authorizes only Host-scoped internal operations + +#### Scenario: Dynamic Host credential calls a public route +- **WHEN** a dynamically enrolled Host credential is presented to a public SDK operation requiring a scope +- **THEN** the request is rejected for missing scope rather than inheriting public operator privileges + +#### Scenario: Static credential deployment remains active +- **WHEN** an operator continues to configure a Host-bound credential through the existing environment configuration +- **THEN** that Host can use the existing operational protocol without performing bootstrap enrollment diff --git a/openspec/changes/edge-host-enrollment/specs/device-pool/spec.md b/openspec/changes/edge-host-enrollment/specs/device-pool/spec.md new file mode 100644 index 0000000..926e3ff --- /dev/null +++ b/openspec/changes/edge-host-enrollment/specs/device-pool/spec.md @@ -0,0 +1,40 @@ +## MODIFIED Requirements + +### Requirement: Authenticated network synchronization feeds the device pool +The system SHALL expose an authenticated Host Agent operation that validates a host device snapshot against its authentication mode and durable device enrollments before delegating it to the existing device-pool synchronization behavior. + +#### Scenario: Valid managed remote snapshot +- **WHEN** an authenticated enrollment-managed Host submits a complete snapshot containing only non-revoked device IDs enrolled to that Host with matching driver types +- **THEN** the device pool refreshes that Host and its devices with the same replacement and staleness semantics as an in-process synchronization call + +#### Scenario: Valid legacy remote snapshot +- **WHEN** an authenticated statically configured Host submits a valid complete snapshot +- **THEN** the device pool preserves the existing compatible synchronization and ownership-conflict behavior + +#### Scenario: Invalid snapshot is rejected atomically +- **WHEN** a Host Agent snapshot contains invalid identifiers, unowned cloud device IDs, conflicting driver metadata, statuses, or capability tags +- **THEN** the control plane rejects the snapshot without partially replacing the Host's previous pooled devices or heartbeat timestamp + +## ADDED Requirements + +### Requirement: Durable device enrollment identity is independent of pool presence +The cloud repository SHALL retain a Host-scoped device enrollment and its assigned `device_id` independently of whether the device appears in the Host's latest heartbeat snapshot. + +#### Scenario: Enrolled device disconnects +- **WHEN** a Host submits a heartbeat that no longer includes a previously enrolled device +- **THEN** the pooled-device projection removes that device while its durable enrollment remains available for later idempotent re-enrollment + +#### Scenario: Enrolled device reconnects +- **WHEN** the Host later enrolls or reports the same local device reference again +- **THEN** the control plane reuses the existing cloud `device_id` + +### Requirement: Managed device identity cannot be claimed by another Host +The device pool SHALL derive managed device ownership from durable enrollment rather than accepting a caller-selected cloud device ID. + +#### Scenario: Host reports another Host's managed device +- **WHEN** Host B includes a cloud device ID enrolled to Host A in its heartbeat +- **THEN** the control plane rejects Host B's snapshot and Host A retains ownership + +#### Scenario: Prior Host becomes stale +- **WHEN** Host A becomes stale and Host B presents Host A's cloud device ID +- **THEN** the control plane still rejects implicit takeover because managed device transfer requires a future explicit operation diff --git a/openspec/changes/edge-host-enrollment/specs/edge-host-enrollment/spec.md b/openspec/changes/edge-host-enrollment/specs/edge-host-enrollment/spec.md new file mode 100644 index 0000000..a8ae378 --- /dev/null +++ b/openspec/changes/edge-host-enrollment/specs/edge-host-enrollment/spec.md @@ -0,0 +1,64 @@ +## ADDED Requirements + +### Requirement: Host bootstrap uses a one-time enrollment credential +The Cloud Control Plane SHALL provide a Host enrollment operation that authenticates a configured bootstrap credential, generates the authoritative `host_id`, and binds it to an edge-generated long-lived Host credential without storing the plaintext Host credential. + +#### Scenario: New edge instance enrolls +- **WHEN** an edge instance presents a valid unused enrollment token, a new instance identifier, and a high-entropy candidate Host credential +- **THEN** the control plane returns a generated `host_id` and durably stores only the credential digest and enrollment binding + +#### Scenario: Enrollment token is invalid +- **WHEN** an edge instance presents an unknown enrollment token +- **THEN** the control plane rejects enrollment without creating a Host identity or consuming any configured token + +#### Scenario: Enrollment token is reused by another instance +- **WHEN** a consumed enrollment token is presented with a different instance identifier +- **THEN** the control plane returns a conflict and preserves the original Host binding + +### Requirement: Host enrollment is idempotent across response loss +The Host enrollment operation SHALL return the existing cloud Host identity when the same instance repeats enrollment with the same bootstrap-token binding and Host credential digest. + +#### Scenario: Identical enrollment is retried +- **WHEN** an edge instance repeats a successful enrollment request after losing the response +- **THEN** the control plane returns the original `host_id` without creating another Host or rotating the submitted Host credential + +#### Scenario: Instance retries with a different Host credential +- **WHEN** an enrolled instance repeats enrollment with a different candidate Host credential +- **THEN** the control plane rejects the request and leaves the original Host credential binding unchanged + +### Requirement: Cloud assigns Host-scoped device identities +An authenticated Host SHALL enroll each local device through an opaque Host-scoped local reference, and the control plane SHALL generate and durably return the `device_id` used by scheduling, heartbeat, leases, and assignment execution. + +#### Scenario: Host enrolls a local device +- **WHEN** an authenticated Host submits a previously unknown local device reference and valid driver metadata +- **THEN** the control plane creates a device enrollment owned by that Host and returns a generated `device_id` + +#### Scenario: Device enrollment is repeated +- **WHEN** the same Host repeats enrollment for the same local device reference +- **THEN** the control plane returns the existing `device_id` and does not create a duplicate enrollment + +#### Scenario: Same local reference appears on another Host +- **WHEN** a different Host enrolls an identical local device reference +- **THEN** the control plane creates a distinct Host-scoped device enrollment rather than silently transferring ownership + +### Requirement: Edge identity and device mappings survive restart +The Host Agent SHALL persist its generated instance identifier, long-lived Host credential, assigned Host ID, and local-to-cloud device mappings outside process memory and SHALL reuse them on later starts. + +#### Scenario: Host Agent restarts after enrollment +- **WHEN** a managed Host Agent restarts with intact identity state +- **THEN** it authenticates with the previously assigned `host_id` and credential without consuming another enrollment token + +#### Scenario: Device mapping is missing but Host identity remains +- **WHEN** a managed Host Agent has its Host identity but lacks a cached mapping for a configured local device +- **THEN** it repeats idempotent device enrollment and restores the original cloud `device_id` + +#### Scenario: Enrollment response is lost before Host ID persistence +- **WHEN** the Host Agent persisted its candidate credential but did not persist the successful response +- **THEN** its next start retries the identical enrollment request and recovers the original `host_id` + +### Requirement: Enrolled Host credentials are revocable +The cloud repository SHALL support revoking a dynamically enrolled Host credential, and authentication SHALL reject revoked credentials without deleting task or attempt history. + +#### Scenario: Revoked Host sends heartbeat +- **WHEN** a Host presents a credential whose enrollment has been revoked +- **THEN** the internal API rejects the request and preserves existing cloud history for that Host and its devices diff --git a/openspec/changes/edge-host-enrollment/specs/host-agent-protocol/spec.md b/openspec/changes/edge-host-enrollment/specs/host-agent-protocol/spec.md new file mode 100644 index 0000000..c0b28f7 --- /dev/null +++ b/openspec/changes/edge-host-enrollment/specs/host-agent-protocol/spec.md @@ -0,0 +1,48 @@ +## MODIFIED Requirements + +### Requirement: Host identity is authenticated and bound to one host id +Except for the bootstrap enrollment operation authenticated by a configured one-time enrollment credential, the internal Host Agent API SHALL require a host-scoped bearer principal and SHALL reject any request that attempts to act for a `host_id` different from the authenticated principal's bound host. + +#### Scenario: Host authenticates as itself +- **WHEN** a Host Agent presents valid static or dynamically enrolled credentials bound to its requested `host_id` +- **THEN** the internal API authorizes permitted device enrollment, heartbeat, claim, renewal, and result operations + +#### Scenario: Host attempts to impersonate another host +- **WHEN** valid credentials bound to host A are used on a request for host B +- **THEN** the internal API rejects the request without reading or modifying host B's state + +#### Scenario: Unknown Host credential is presented +- **WHEN** a caller presents a bearer credential that is neither statically configured nor bound to a non-revoked enrolled Host +- **THEN** the internal API rejects the request without exposing whether a Host ID exists + +### Requirement: Host Agent synchronizes heartbeat and complete device snapshots +The Host Agent SHALL periodically submit its complete local device snapshot to the control plane, and the control plane SHALL atomically refresh the host heartbeat and replace only that host's pooled-device records. Enrollment-managed Hosts SHALL report the cloud device IDs assigned through device enrollment. + +#### Scenario: Managed Host reports enrolled devices +- **WHEN** an enrollment-managed Host Agent submits a valid heartbeat containing cloud device IDs enrolled to that Host +- **THEN** the control plane updates the host's last-seen time and exposes the submitted devices through the aggregated pool + +#### Scenario: Managed Host reports an unknown device ID +- **WHEN** an enrollment-managed Host reports a device ID not enrolled to that Host or a driver type that conflicts with its enrollment +- **THEN** the control plane rejects the entire snapshot without changing the previous heartbeat or pooled-device state + +#### Scenario: Legacy Host reports devices +- **WHEN** a statically configured legacy Host submits a valid device snapshot +- **THEN** the control plane retains the existing compatible snapshot and ownership-conflict behavior + +#### Scenario: Host reports no devices +- **WHEN** a previously populated Host submits an empty device snapshot +- **THEN** only that Host's prior pooled-device records are removed while durable enrollment records and devices owned by other Hosts remain unchanged + +## ADDED Requirements + +### Requirement: Managed Host startup enrolls before normal protocol loops +The Host Agent SHALL resolve its Host identity and cloud device mappings before starting heartbeat synchronization or assignment long-polling. + +#### Scenario: New managed Host starts +- **WHEN** the Host Agent has an enrollment token but no completed local Host identity +- **THEN** it performs idempotent Host enrollment, enrolls configured local devices, constructs its `DeviceManager` with cloud device IDs, and only then starts heartbeat and claim loops + +#### Scenario: Explicit legacy credentials are configured +- **WHEN** both `HOST_AGENT_HOST_ID` and `HOST_AGENT_TOKEN` are explicitly supplied +- **THEN** the Host Agent skips bootstrap enrollment and preserves existing local device ID behavior diff --git a/openspec/changes/edge-host-enrollment/tasks.md b/openspec/changes/edge-host-enrollment/tasks.md new file mode 100644 index 0000000..a124a51 --- /dev/null +++ b/openspec/changes/edge-host-enrollment/tasks.md @@ -0,0 +1,40 @@ +## 1. Durable Enrollment Repository + +- [x] 1.1 Add Host and device enrollment records plus repository protocol operations for atomic enrollment, credential lookup/revocation, device lookup, and managed-Host detection. +- [x] 1.2 Extend SQLAlchemy models and add Alembic revision 0002 with forward/downgrade support, indexes, uniqueness constraints, and the updated schema head. +- [x] 1.3 Implement atomic Host enrollment idempotency and one-time enrollment-token consumption in the SQL repository for SQLite and PostgreSQL. +- [x] 1.4 Implement dynamic credential lookup/revocation and idempotent Host-scoped device enrollment in the SQL repository. +- [x] 1.5 Add repository contract and migration tests covering success, retries, token reuse conflicts, revocation, per-Host device identity, and schema preservation. + +## 2. Cloud Authentication And Internal API + +- [x] 2.1 Parse and validate `CLOUD_ENROLLMENT_TOKENS_JSON` without exposing token values, while retaining existing configured bearer credentials. +- [x] 2.2 Add enrollment-token verification, repository-backed dynamic Host authentication, and composed authentication providers. +- [x] 2.3 Add Host/device enrollment request-response models and authenticated internal API routes with generated cloud IDs and conflict handling. +- [x] 2.4 Require enrollment-managed Host heartbeat snapshots to contain only enrolled, non-revoked device IDs with matching driver types while preserving legacy snapshot behavior. +- [x] 2.5 Compose enrollment services into the deployable Cloud API and add HTTP tests for bootstrap, idempotency, authorization, device enrollment, heartbeat validation, restart authentication, and public-scope isolation. + +## 3. Edge Identity And Device Mapping Storage + +- [x] 3.1 Add an atomic, restrictive-permission Host identity store that persists pending and completed enrollment state across response loss and restart. +- [x] 3.2 Extend Host Agent configuration to support explicit legacy credentials or managed enrollment with configurable token and identity-state path. +- [x] 3.3 Extend `DeviceConfigStore` with nullable cloud device mappings and backward-compatible schema upgrade/read/write operations. +- [x] 3.4 Add focused tests for identity-state recovery, configuration mode validation, file secrecy behavior, and device mapping persistence. + +## 4. Host Agent Enrollment Startup + +- [x] 4.1 Extend the Host Agent client with synchronous bootstrap/device-enrollment operations that preserve existing retry and typed-error behavior. +- [x] 4.2 Resolve managed Host identity before application construction, persisting the candidate secret before the request and assigned Host ID after success. +- [x] 4.3 Enroll configured local devices before building the managed `DeviceManager`, persist mappings, and register drivers under cloud device IDs while leaving legacy mode unchanged. +- [x] 4.4 Update Host Agent application and heartbeat/assignment tests for first enrollment, response-loss retry, restart reuse, device mapping recovery, legacy compatibility, and startup ordering. + +## 5. Deployment And Operations + +- [x] 5.1 Add enrollment-token and identity-path variables to example/Compose deployment configuration without overwriting existing registry/image customizations. +- [x] 5.2 Update cloud and macOS edge deployment documentation for managed enrollment, secret/state handling, static fallback, revocation, migration, rollback, and outbound-only networking. + +## 6. Verification + +- [x] 6.1 Run formatting/static checks and focused repository, migration, Cloud API, Host Agent, deployment-contract, and end-to-end tests. +- [x] 6.2 Run the complete non-integration workspace test suite and resolve regressions without modifying unrelated cloud-console work. +- [x] 6.3 Run strict OpenSpec validation, review the final diff for credential leakage and architecture-boundary violations, and record verified limitations. diff --git a/packages/cloud-platform/cloud/auth.py b/packages/cloud-platform/cloud/auth.py index 97a4b70..7fce34b 100644 --- a/packages/cloud-platform/cloud/auth.py +++ b/packages/cloud-platform/cloud/auth.py @@ -4,7 +4,10 @@ from dataclasses import dataclass, field from hashlib import sha256 from hmac import compare_digest from collections.abc import Iterable -from typing import Protocol, runtime_checkable +from typing import TYPE_CHECKING, Protocol, runtime_checkable + +if TYPE_CHECKING: + from cloud.repository import CloudRepository TASKS_SUBMIT_SCOPE = "tasks:submit" @@ -78,6 +81,24 @@ class _StoredBearerCredential: token_digest: bytes = field(repr=False) +@dataclass(frozen=True) +class EnrollmentCredential: + principal_id: str + token: str = field(repr=False) + + def __post_init__(self) -> None: + if not self.principal_id.strip(): + raise ValueError("enrollment credential principal_id must not be empty") + if not self.token: + raise ValueError("enrollment credential token must not be empty") + + +@dataclass(frozen=True) +class EnrollmentPrincipal: + id: str + token_digest: str = field(repr=False) + + class ConfiguredBearerAuthProvider: """Authenticate configured bearer tokens without exposing credential text.""" @@ -107,6 +128,57 @@ class ConfiguredBearerAuthProvider: return matched_principal +class ConfiguredEnrollmentTokenProvider: + def __init__(self, credentials: Iterable[EnrollmentCredential]) -> None: + self._credentials = tuple( + EnrollmentPrincipal( + id=credential.principal_id, + token_digest=digest_token(credential.token), + ) + for credential in credentials + ) + + def authenticate(self, request: object) -> EnrollmentPrincipal | None: + candidate = bearer_token_digest(request) + if candidate is None: + return None + candidate_bytes = bytes.fromhex(candidate) + matched: EnrollmentPrincipal | None = None + for credential in self._credentials: + if compare_digest( + candidate_bytes, + bytes.fromhex(credential.token_digest), + ): + matched = credential + return matched + + +class RepositoryHostAuthProvider: + def __init__(self, repository: CloudRepository) -> None: + self.repository = repository + + def authenticate(self, request: object) -> Principal | None: + credential_digest = bearer_token_digest(request) + if credential_digest is None: + return None + host_id = self.repository.authenticate_enrolled_host(credential_digest) + if host_id is None: + return None + return Principal(id=f"enrolled-host:{host_id}", host_id=host_id) + + +class ChainedAuthProvider: + def __init__(self, providers: Iterable[AuthProvider]) -> None: + self.providers = tuple(providers) + + def authenticate(self, request: object) -> Principal | None: + for provider in self.providers: + principal = provider.authenticate(request) + if principal is not None: + return principal + return None + + def create_auth_provider( credentials: Iterable[BearerCredential], *, @@ -133,5 +205,14 @@ def _extract_bearer_token(request: object) -> str | None: return token +def bearer_token_digest(request: object) -> str | None: + token = _extract_bearer_token(request) + return digest_token(token) if token is not None else None + + +def digest_token(token: str) -> str: + return _token_digest(token).hex() + + def _token_digest(token: str) -> bytes: return sha256(token.encode("utf-8")).digest() diff --git a/packages/cloud-platform/cloud/control_config.py b/packages/cloud-platform/cloud/control_config.py index 4927539..0f747b3 100644 --- a/packages/cloud-platform/cloud/control_config.py +++ b/packages/cloud-platform/cloud/control_config.py @@ -6,7 +6,7 @@ from collections.abc import Mapping from dataclasses import dataclass from typing import Literal -from cloud.auth import BearerCredential +from cloud.auth import BearerCredential, EnrollmentCredential EnvironmentName = Literal["local", "test", "production"] @@ -31,6 +31,7 @@ class CloudControlConfig: max_task_attempts: int = 3 allow_insecure_anonymous: bool = False credentials: tuple[BearerCredential, ...] = () + enrollment_credentials: tuple[EnrollmentCredential, ...] = () def load_control_config( @@ -86,6 +87,9 @@ def load_control_config( require_host_id=True, ), ), + enrollment_credentials=_parse_enrollment_credentials( + values.get("CLOUD_ENROLLMENT_TOKENS_JSON") + ), ) validate_control_config(config) return config @@ -145,6 +149,33 @@ def _parse_credentials( ) from exc +def _parse_enrollment_credentials( + raw_value: str | None, +) -> tuple[EnrollmentCredential, ...]: + if raw_value is None or not raw_value.strip(): + return () + try: + payload = json.loads(raw_value) + if not isinstance(payload, list): + raise TypeError + credentials: list[EnrollmentCredential] = [] + for item in payload: + if not isinstance(item, dict): + raise TypeError + principal_id = item.get("principal_id") + token = item.get("token") + if not isinstance(principal_id, str) or not isinstance(token, str): + raise TypeError + credentials.append( + EnrollmentCredential(principal_id=principal_id, token=token) + ) + return tuple(credentials) + except (TypeError, ValueError, json.JSONDecodeError) as exc: + raise CloudConfigurationError( + "configured enrollment credentials are invalid" + ) from exc + + def _positive_float( values: Mapping[str, str], name: str, diff --git a/packages/cloud-platform/cloud/db_models.py b/packages/cloud-platform/cloud/db_models.py index 3fe5648..3f76010 100644 --- a/packages/cloud-platform/cloud/db_models.py +++ b/packages/cloud-platform/cloud/db_models.py @@ -1,6 +1,6 @@ from __future__ import annotations -from sqlalchemy import Integer, String, Text, text +from sqlalchemy import Index, Integer, String, Text, UniqueConstraint, text from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column @@ -10,10 +10,60 @@ class Base(DeclarativeBase): class HostRow(Base): __tablename__ = "host_registrations" + __table_args__ = ( + UniqueConstraint( + "agent_instance_id", + name="uq_host_registrations_agent_instance_id", + ), + UniqueConstraint( + "credential_digest", + name="uq_host_registrations_credential_digest", + ), + UniqueConstraint( + "enrollment_token_digest", + name="uq_host_registrations_enrollment_token_digest", + ), + ) host_id: Mapped[str] = mapped_column(String, primary_key=True) address: Mapped[str | None] = mapped_column(String, nullable=True) last_seen_at: Mapped[str] = mapped_column(String, nullable=False) + agent_instance_id: Mapped[str | None] = mapped_column( + String, + nullable=True, + ) + credential_digest: Mapped[str | None] = mapped_column( + String, + nullable=True, + ) + enrollment_token_digest: Mapped[str | None] = mapped_column( + String, + nullable=True, + ) + display_name: Mapped[str | None] = mapped_column(String, nullable=True) + enrolled_at: Mapped[str | None] = mapped_column(String, nullable=True) + revoked_at: Mapped[str | None] = mapped_column(String, nullable=True) + + +class DeviceEnrollmentRow(Base): + __tablename__ = "device_enrollments" + __table_args__ = ( + UniqueConstraint( + "host_id", + "local_device_id", + name="uq_device_enrollments_host_local", + ), + Index("ix_device_enrollments_host_id", "host_id"), + ) + + device_id: Mapped[str] = mapped_column(String, primary_key=True) + host_id: Mapped[str] = mapped_column(String, nullable=False) + local_device_id: Mapped[str] = mapped_column(String, nullable=False) + driver_type: Mapped[str] = mapped_column(String, nullable=False) + name: Mapped[str | None] = mapped_column(String, nullable=True) + capability_tags_json: Mapped[str] = mapped_column(Text, nullable=False) + enrolled_at: Mapped[str] = mapped_column(String, nullable=False) + revoked_at: Mapped[str | None] = mapped_column(String, nullable=True) class PooledDeviceRow(Base): diff --git a/packages/cloud-platform/cloud/internal_api/api.py b/packages/cloud-platform/cloud/internal_api/api.py index e2d99c8..1991e09 100644 --- a/packages/cloud-platform/cloud/internal_api/api.py +++ b/packages/cloud-platform/cloud/internal_api/api.py @@ -5,26 +5,38 @@ from collections.abc import Awaitable, Callable from datetime import timedelta from time import monotonic from typing import TYPE_CHECKING +from uuid import uuid4 from fastapi import APIRouter, HTTPException, Request, status from fastapi.responses import JSONResponse from cloud.auth import ( AuthProvider, + ConfiguredEnrollmentTokenProvider, HostAuthorizationError, + digest_token, ) from cloud.internal_api.models import ( AssignmentModel, ClaimRequest, ClaimResponse, + DeviceEnrollmentRequest, + DeviceEnrollmentResponse, HeartbeatRequest, HeartbeatResponse, + HostEnrollmentRequest, + HostEnrollmentResponse, LeaseRenewalRequest, LeaseRenewalResponse, StaleLeaseConflict, TerminalResultRequest, TerminalResultResponse, ) +from cloud.repository import ( + DeviceEnrollmentConflictError, + EnrollmentTokenConflictError, + HostEnrollmentConflictError, +) from core.models import Device, utc_now if TYPE_CHECKING: @@ -35,6 +47,7 @@ def create_internal_router( *, pool: DevicePool, auth_provider: AuthProvider, + enrollment_auth_provider: ConfiguredEnrollmentTokenProvider | None = None, version_prefix: str = "/internal/v1", claim_poll_interval_seconds: float = 0.1, lease_duration_seconds: float = 60.0, @@ -45,6 +58,7 @@ def create_internal_router( if lease_duration_seconds <= 0: raise ValueError("lease_duration_seconds must be greater than zero") router = APIRouter(prefix=version_prefix, tags=["host-agent"]) + enrollment_auth = enrollment_auth_provider or ConfiguredEnrollmentTokenProvider(()) def authorize_host(request: Request, host_id: str) -> None: principal = auth_provider.authenticate(request) @@ -62,6 +76,66 @@ def create_internal_router( detail=str(exc), ) from exc + @router.post( + "/enrollments", + response_model=HostEnrollmentResponse, + status_code=status.HTTP_201_CREATED, + ) + def enroll_host( + payload: HostEnrollmentRequest, + request: Request, + ) -> HostEnrollmentResponse: + enrollment_principal = enrollment_auth.authenticate(request) + if enrollment_principal is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="unauthorized", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + enrollment = pool.store.enroll_host( + host_id=f"host-{uuid4().hex}", + agent_instance_id=payload.agent_instance_id, + credential_digest=digest_token(payload.host_token), + enrollment_token_digest=enrollment_principal.token_digest, + display_name=payload.display_name, + enrolled_at=utc_now(), + ) + except (EnrollmentTokenConflictError, HostEnrollmentConflictError) as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=str(exc), + ) from exc + return HostEnrollmentResponse(host_id=enrollment.host_id) + + @router.post( + "/hosts/{host_id}/devices/enroll", + response_model=DeviceEnrollmentResponse, + status_code=status.HTTP_201_CREATED, + ) + def enroll_device( + host_id: str, + payload: DeviceEnrollmentRequest, + request: Request, + ) -> DeviceEnrollmentResponse: + authorize_host(request, host_id) + try: + enrollment = pool.store.enroll_device( + device_id=f"device-{uuid4().hex}", + host_id=host_id, + local_device_id=payload.local_device_id, + driver_type=payload.driver_type, + name=payload.name, + capability_tags=list(payload.capability_tags), + enrolled_at=utc_now(), + ) + except DeviceEnrollmentConflictError as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=str(exc), + ) from exc + return DeviceEnrollmentResponse(device_id=enrollment.device_id) + @router.put( "/hosts/{host_id}/heartbeat", response_model=HeartbeatResponse, @@ -250,6 +324,25 @@ def _validate_snapshot( detail="heartbeat snapshot contains duplicate device ids", ) + if pool.store.is_enrollment_managed_host(host_id): + enrollments = { + enrollment.device_id: enrollment + for enrollment in pool.store.list_device_enrollments(host_id) + if enrollment.revoked_at is None + } + invalid = [ + device.device_id + for device in payload.devices + if device.device_id not in enrollments + or enrollments[device.device_id].driver_type != device.driver_type + ] + if invalid: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"device enrollment conflict: {sorted(set(invalid))}", + ) + return False + now = utc_now() hosts = {host.host_id: host for host in pool.store.list_hosts()} conflicts: list[str] = [] diff --git a/packages/cloud-platform/cloud/internal_api/models.py b/packages/cloud-platform/cloud/internal_api/models.py index a76946c..ec4997f 100644 --- a/packages/cloud-platform/cloud/internal_api/models.py +++ b/packages/cloud-platform/cloud/internal_api/models.py @@ -6,6 +6,27 @@ from typing import Any, Literal from pydantic import BaseModel, Field +class HostEnrollmentRequest(BaseModel): + agent_instance_id: str = Field(min_length=1, max_length=256) + host_token: str = Field(min_length=32, max_length=512) + display_name: str | None = Field(default=None, max_length=256) + + +class HostEnrollmentResponse(BaseModel): + host_id: str + + +class DeviceEnrollmentRequest(BaseModel): + local_device_id: str = Field(min_length=1, max_length=256) + driver_type: str = Field(min_length=1, max_length=128) + name: str | None = Field(default=None, max_length=256) + capability_tags: list[str] = Field(default_factory=list) + + +class DeviceEnrollmentResponse(BaseModel): + device_id: str + + class DeviceSnapshotModel(BaseModel): device_id: str = Field(min_length=1) driver_type: str = Field(min_length=1) diff --git a/packages/cloud-platform/cloud/migrations/versions/0002_edge_host_enrollment.py b/packages/cloud-platform/cloud/migrations/versions/0002_edge_host_enrollment.py new file mode 100644 index 0000000..040a3eb --- /dev/null +++ b/packages/cloud-platform/cloud/migrations/versions/0002_edge_host_enrollment.py @@ -0,0 +1,96 @@ +"""Add durable Host and device enrollment identity.""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "0002_edge_host_enrollment" +down_revision = "0001_cloud_repository" +branch_labels = None +depends_on = None + + +HOST_ENROLLMENT_COLUMNS = ( + sa.Column("agent_instance_id", sa.String(), nullable=True), + sa.Column("credential_digest", sa.String(), nullable=True), + sa.Column("enrollment_token_digest", sa.String(), nullable=True), + sa.Column("display_name", sa.String(), nullable=True), + sa.Column("enrolled_at", sa.String(), nullable=True), + sa.Column("revoked_at", sa.String(), nullable=True), +) + + +def upgrade() -> None: + inspector = sa.inspect(op.get_bind()) + existing_columns = { + column["name"] for column in inspector.get_columns("host_registrations") + } + with op.batch_alter_table("host_registrations") as batch: + for column in HOST_ENROLLMENT_COLUMNS: + if column.name not in existing_columns: + batch.add_column(column) + + inspector = sa.inspect(op.get_bind()) + indexes = {index["name"] for index in inspector.get_indexes("host_registrations")} + with op.batch_alter_table("host_registrations") as batch: + for name, column in ( + ("uq_host_registrations_agent_instance_id", "agent_instance_id"), + ("uq_host_registrations_credential_digest", "credential_digest"), + ( + "uq_host_registrations_enrollment_token_digest", + "enrollment_token_digest", + ), + ): + if name not in indexes: + batch.create_unique_constraint(name, [column]) + + inspector = sa.inspect(op.get_bind()) + if "device_enrollments" not in inspector.get_table_names(): + op.create_table( + "device_enrollments", + sa.Column("device_id", sa.String(), primary_key=True), + sa.Column("host_id", sa.String(), nullable=False), + sa.Column("local_device_id", sa.String(), nullable=False), + sa.Column("driver_type", sa.String(), nullable=False), + sa.Column("name", sa.String(), nullable=True), + sa.Column("capability_tags_json", sa.Text(), nullable=False), + sa.Column("enrolled_at", sa.String(), nullable=False), + sa.Column("revoked_at", sa.String(), nullable=True), + sa.UniqueConstraint( + "host_id", + "local_device_id", + name="uq_device_enrollments_host_local", + ), + ) + op.create_index( + "ix_device_enrollments_host_id", + "device_enrollments", + ["host_id"], + ) + + +def downgrade() -> None: + inspector = sa.inspect(op.get_bind()) + if "device_enrollments" in inspector.get_table_names(): + op.drop_index( + "ix_device_enrollments_host_id", + table_name="device_enrollments", + ) + op.drop_table("device_enrollments") + + inspector = sa.inspect(op.get_bind()) + existing_columns = { + column["name"] for column in inspector.get_columns("host_registrations") + } + with op.batch_alter_table("host_registrations") as batch: + for name in ( + "uq_host_registrations_enrollment_token_digest", + "uq_host_registrations_credential_digest", + "uq_host_registrations_agent_instance_id", + ): + batch.drop_constraint(name, type_="unique") + for column in reversed(HOST_ENROLLMENT_COLUMNS): + if column.name in existing_columns: + batch.drop_column(column.name) diff --git a/packages/cloud-platform/cloud/repository.py b/packages/cloud-platform/cloud/repository.py index 8f538b0..46afe67 100644 --- a/packages/cloud-platform/cloud/repository.py +++ b/packages/cloud-platform/cloud/repository.py @@ -16,6 +16,39 @@ ResultRecordStatus = Literal["recorded", "already_recorded", "conflict"] LeaseRenewalStatus = Literal["renewed", "not_found", "conflict", "expired"] +class HostEnrollmentConflictError(RuntimeError): + """Raised when a Host enrollment cannot preserve its existing binding.""" + + +class EnrollmentTokenConflictError(HostEnrollmentConflictError): + """Raised when a one-time enrollment token is already bound elsewhere.""" + + +class DeviceEnrollmentConflictError(RuntimeError): + """Raised when a local device enrollment conflicts with stored identity.""" + + +@dataclass(frozen=True) +class HostEnrollment: + host_id: str + agent_instance_id: str + display_name: str | None + enrolled_at: datetime + revoked_at: datetime | None = None + + +@dataclass(frozen=True) +class DeviceEnrollment: + device_id: str + host_id: str + local_device_id: str + driver_type: str + name: str | None + capability_tags: list[str] + enrolled_at: datetime + revoked_at: datetime | None = None + + @dataclass(frozen=True) class TaskAttemptRecord: task_id: str @@ -46,6 +79,53 @@ class LeasedAssignment: class CloudRepository(Protocol): """Persistence port for cloud state and atomic scheduling operations.""" + def enroll_host( + self, + *, + host_id: str, + agent_instance_id: str, + credential_digest: str, + enrollment_token_digest: str, + display_name: str | None, + enrolled_at: datetime, + ) -> HostEnrollment: ... + + def authenticate_enrolled_host( + self, + credential_digest: str, + ) -> str | None: ... + + def revoke_enrolled_host( + self, + host_id: str, + *, + revoked_at: datetime, + ) -> bool: ... + + def is_enrollment_managed_host(self, host_id: str) -> bool: ... + + def enroll_device( + self, + *, + device_id: str, + host_id: str, + local_device_id: str, + driver_type: str, + name: str | None, + capability_tags: list[str], + enrolled_at: datetime, + ) -> DeviceEnrollment: ... + + def get_device_enrollment( + self, + device_id: str, + ) -> DeviceEnrollment | None: ... + + def list_device_enrollments( + self, + host_id: str, + ) -> list[DeviceEnrollment]: ... + def upsert_host( self, host_id: str, diff --git a/packages/cloud-platform/cloud/schema.py b/packages/cloud-platform/cloud/schema.py index 4149b93..6812aee 100644 --- a/packages/cloud-platform/cloud/schema.py +++ b/packages/cloud-platform/cloud/schema.py @@ -9,7 +9,7 @@ from alembic.runtime.migration import MigrationContext from cloud.database import create_database_engine, normalize_database_url -HEAD_REVISION = "0001_cloud_repository" +HEAD_REVISION = "0002_edge_host_enrollment" class SchemaVersionError(RuntimeError): diff --git a/packages/cloud-platform/cloud/sql_repository.py b/packages/cloud-platform/cloud/sql_repository.py index 8959724..a6718a0 100644 --- a/packages/cloud-platform/cloud/sql_repository.py +++ b/packages/cloud-platform/cloud/sql_repository.py @@ -7,10 +7,12 @@ from datetime import datetime from typing import Any from sqlalchemy import Engine, delete, func, select +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import sessionmaker from cloud.db_models import ( Base, + DeviceEnrollmentRow, HostRow, PluginRow, PooledDeviceRow, @@ -33,6 +35,211 @@ class SQLAlchemyCloudRepository: if create_schema: Base.metadata.create_all(engine) + def enroll_host( + self, + *, + host_id: str, + agent_instance_id: str, + credential_digest: str, + enrollment_token_digest: str, + display_name: str | None, + enrolled_at: datetime, + ) -> Any: + from cloud.repository import ( + EnrollmentTokenConflictError, + HostEnrollmentConflictError, + ) + + try: + with self._sessions.begin() as session: + statement = select(HostRow).where( + HostRow.agent_instance_id == agent_instance_id + ) + if self.engine.dialect.name == "postgresql": + statement = statement.with_for_update() + existing = session.scalars(statement).first() + if existing is not None: + if ( + existing.credential_digest != credential_digest + or existing.enrollment_token_digest != enrollment_token_digest + ): + raise HostEnrollmentConflictError( + "Host enrollment identity does not match existing binding" + ) + return _host_enrollment_from_row(existing) + + token_owner = session.scalars( + select(HostRow).where( + HostRow.enrollment_token_digest == enrollment_token_digest + ) + ).first() + if token_owner is not None: + raise EnrollmentTokenConflictError( + "enrollment token is already bound to another Host" + ) + + row = HostRow( + host_id=host_id, + address=None, + last_seen_at=_iso(enrolled_at), + agent_instance_id=agent_instance_id, + credential_digest=credential_digest, + enrollment_token_digest=enrollment_token_digest, + display_name=display_name, + enrolled_at=_iso(enrolled_at), + revoked_at=None, + ) + session.add(row) + session.flush() + return _host_enrollment_from_row(row) + except IntegrityError as exc: + with self._sessions() as session: + existing = session.scalars( + select(HostRow).where( + HostRow.agent_instance_id == agent_instance_id + ) + ).first() + if ( + existing is not None + and existing.credential_digest == credential_digest + and existing.enrollment_token_digest == enrollment_token_digest + ): + return _host_enrollment_from_row(existing) + token_owner = session.scalars( + select(HostRow).where( + HostRow.enrollment_token_digest == enrollment_token_digest + ) + ).first() + if token_owner is not None: + raise EnrollmentTokenConflictError( + "enrollment token is already bound to another Host" + ) from exc + raise HostEnrollmentConflictError( + "Host enrollment conflicts with an existing identity" + ) from exc + + def authenticate_enrolled_host(self, credential_digest: str) -> str | None: + with self._sessions() as session: + host_id = session.scalar( + select(HostRow.host_id) + .where( + HostRow.credential_digest == credential_digest, + HostRow.revoked_at.is_(None), + ) + .limit(1) + ) + return str(host_id) if host_id is not None else None + + def revoke_enrolled_host( + self, + host_id: str, + *, + revoked_at: datetime, + ) -> bool: + with self._sessions.begin() as session: + row = session.get( + HostRow, + host_id, + with_for_update=self.engine.dialect.name == "postgresql", + ) + if row is None or row.credential_digest is None: + return False + row.revoked_at = _iso(revoked_at) + return True + + def is_enrollment_managed_host(self, host_id: str) -> bool: + with self._sessions() as session: + credential_digest = session.scalar( + select(HostRow.credential_digest).where(HostRow.host_id == host_id) + ) + return credential_digest is not None + + def enroll_device( + self, + *, + device_id: str, + host_id: str, + local_device_id: str, + driver_type: str, + name: str | None, + capability_tags: list[str], + enrolled_at: datetime, + ) -> Any: + from cloud.repository import DeviceEnrollmentConflictError + + tags_json = json.dumps(list(capability_tags), ensure_ascii=False) + try: + with self._sessions.begin() as session: + statement = select(DeviceEnrollmentRow).where( + DeviceEnrollmentRow.host_id == host_id, + DeviceEnrollmentRow.local_device_id == local_device_id, + ) + if self.engine.dialect.name == "postgresql": + statement = statement.with_for_update() + existing = session.scalars(statement).first() + if existing is not None: + if existing.driver_type != driver_type: + raise DeviceEnrollmentConflictError( + "device driver type does not match existing enrollment" + ) + if existing.revoked_at is not None: + raise DeviceEnrollmentConflictError( + "device enrollment has been revoked" + ) + existing.name = name + existing.capability_tags_json = tags_json + return _device_enrollment_from_row(existing) + + host = session.get(HostRow, host_id) + if host is None: + session.add( + HostRow( + host_id=host_id, + address=None, + last_seen_at=_iso(enrolled_at), + ) + ) + row = DeviceEnrollmentRow( + device_id=device_id, + host_id=host_id, + local_device_id=local_device_id, + driver_type=driver_type, + name=name, + capability_tags_json=tags_json, + enrolled_at=_iso(enrolled_at), + revoked_at=None, + ) + session.add(row) + session.flush() + return _device_enrollment_from_row(row) + except IntegrityError as exc: + with self._sessions() as session: + existing = session.scalars( + select(DeviceEnrollmentRow).where( + DeviceEnrollmentRow.host_id == host_id, + DeviceEnrollmentRow.local_device_id == local_device_id, + ) + ).first() + if existing is not None and existing.driver_type == driver_type: + return _device_enrollment_from_row(existing) + raise DeviceEnrollmentConflictError( + "device enrollment conflicts with an existing identity" + ) from exc + + def get_device_enrollment(self, device_id: str) -> Any | None: + with self._sessions() as session: + row = session.get(DeviceEnrollmentRow, device_id) + return _device_enrollment_from_row(row) if row else None + + def list_device_enrollments(self, host_id: str) -> list[Any]: + with self._sessions() as session: + rows = session.scalars( + select(DeviceEnrollmentRow) + .where(DeviceEnrollmentRow.host_id == host_id) + .order_by(DeviceEnrollmentRow.device_id) + ).all() + return [_device_enrollment_from_row(row) for row in rows] + def upsert_host( self, host_id: str, @@ -568,6 +775,40 @@ def _host_from_row(row: HostRow) -> Any: ) +def _host_enrollment_from_row(row: HostRow) -> Any: + from cloud.repository import HostEnrollment + + enrolled_at = _parse_dt(row.enrolled_at) or _parse_dt(row.last_seen_at) or utc_now() + if row.agent_instance_id is None: + raise ValueError(f"Host {row.host_id!r} is not enrollment-managed") + return HostEnrollment( + host_id=row.host_id, + agent_instance_id=row.agent_instance_id, + display_name=row.display_name, + enrolled_at=enrolled_at, + revoked_at=_parse_dt(row.revoked_at), + ) + + +def _device_enrollment_from_row(row: DeviceEnrollmentRow) -> Any: + from cloud.repository import DeviceEnrollment + + try: + tags = list(json.loads(row.capability_tags_json)) + except TypeError, ValueError: + tags = [] + return DeviceEnrollment( + device_id=row.device_id, + host_id=row.host_id, + local_device_id=row.local_device_id, + driver_type=row.driver_type, + name=row.name, + capability_tags=tags, + enrolled_at=_parse_dt(row.enrolled_at) or utc_now(), + revoked_at=_parse_dt(row.revoked_at), + ) + + def _device_from_row(row: PooledDeviceRow) -> Any: from cloud.pool import PooledDevice diff --git a/storage/device_config.py b/storage/device_config.py index 66f7342..f5fe17f 100644 --- a/storage/device_config.py +++ b/storage/device_config.py @@ -28,22 +28,42 @@ class DeviceConfigStore: name: str | None = None, driver_type: str, connection_info: dict[str, Any], + cloud_device_id: str | None = None, ) -> None: with self._connect() as connection: connection.execute( """ - insert or replace into device_configs ( - device_id, name, driver_type, connection_info - ) values (?, ?, ?, ?) + insert into device_configs ( + device_id, name, driver_type, connection_info, cloud_device_id + ) values (?, ?, ?, ?, ?) + on conflict(device_id) do update set + name = excluded.name, + driver_type = excluded.driver_type, + connection_info = excluded.connection_info, + cloud_device_id = coalesce( + excluded.cloud_device_id, + device_configs.cloud_device_id + ) """, ( device_id, name, driver_type, json.dumps(connection_info, ensure_ascii=False), + cloud_device_id, ), ) + def set_cloud_device_id(self, device_id: str, cloud_device_id: str) -> bool: + if not cloud_device_id: + raise ValueError("cloud_device_id must not be empty") + with self._connect() as connection: + cursor = connection.execute( + "update device_configs set cloud_device_id = ? where device_id = ?", + (cloud_device_id, device_id), + ) + return cursor.rowcount > 0 + def remove(self, device_id: str) -> None: with self._connect() as connection: connection.execute( @@ -93,10 +113,19 @@ class DeviceConfigStore: device_id text primary key, name text, driver_type text not null, - connection_info text not null + connection_info text not null, + cloud_device_id text ) """ ) + columns = { + row["name"] + for row in connection.execute("pragma table_info(device_configs)") + } + if "cloud_device_id" not in columns: + connection.execute( + "alter table device_configs add column cloud_device_id text" + ) connection.execute( """ create table if not exists settings ( @@ -121,4 +150,5 @@ class DeviceConfigStore: "name": row["name"], "driver_type": row["driver_type"], "connection_info": json.loads(row["connection_info"]), + "cloud_device_id": row["cloud_device_id"], } diff --git a/tests/test_cloud_auth.py b/tests/test_cloud_auth.py index 56c1dc2..2074510 100644 --- a/tests/test_cloud_auth.py +++ b/tests/test_cloud_auth.py @@ -6,10 +6,15 @@ import cloud.auth as auth_module import pytest from cloud.auth import ( BearerCredential, + ChainedAuthProvider, ConfiguredBearerAuthProvider, + ConfiguredEnrollmentTokenProvider, + EnrollmentCredential, HostIdentityMismatchError, HostPrincipalRequiredError, NullAuthProvider, + RepositoryHostAuthProvider, + digest_token, ) @@ -162,3 +167,49 @@ def test_authentication_failure_does_not_log_bearer_secret(caplog) -> None: ) assert "invalid-secret" not in caplog.text assert "valid-secret" not in caplog.text + + +def test_enrollment_token_provider_returns_digest_without_exposing_secret() -> None: + credential = EnrollmentCredential( + principal_id="installer-a", + token="one-time-enrollment-secret", + ) + provider = ConfiguredEnrollmentTokenProvider([credential]) + + principal = provider.authenticate( + _Request(headers={"authorization": "Bearer one-time-enrollment-secret"}) + ) + + assert principal is not None + assert principal.id == "installer-a" + assert principal.token_digest == digest_token("one-time-enrollment-secret") + assert "one-time-enrollment-secret" not in repr(credential) + assert "one-time-enrollment-secret" not in repr(provider.__dict__) + + +def test_repository_host_auth_and_chain_preserve_host_scope() -> None: + class Repository: + def authenticate_enrolled_host(self, credential_digest: str) -> str | None: + if credential_digest == digest_token("dynamic-host-secret"): + return "host-managed" + return None + + configured = ConfiguredBearerAuthProvider( + [BearerCredential(principal_id="sdk", token="sdk-secret")] + ) + provider = ChainedAuthProvider( + [configured, RepositoryHostAuthProvider(Repository())] # type: ignore[arg-type] + ) + + public_principal = provider.authenticate( + _Request(headers={"authorization": "Bearer sdk-secret"}) + ) + host_principal = provider.authenticate( + _Request(headers={"authorization": "Bearer dynamic-host-secret"}) + ) + + assert public_principal is not None + assert public_principal.id == "sdk" + assert host_principal is not None + assert host_principal.host_id == "host-managed" + assert host_principal.scopes == frozenset() diff --git a/tests/test_cloud_control_config.py b/tests/test_cloud_control_config.py index 2bb563c..76adafc 100644 --- a/tests/test_cloud_control_config.py +++ b/tests/test_cloud_control_config.py @@ -47,6 +47,20 @@ def test_load_control_config_parses_deployment_values() -> None: assert "sdk-secret" not in repr(config) +def test_load_control_config_parses_enrollment_credentials() -> None: + config = load_control_config( + { + "CLOUD_ENROLLMENT_TOKENS_JSON": ( + '[{"principal_id":"installer-a","token":"one-time-enrollment-secret"}]' + ) + } + ) + + assert len(config.enrollment_credentials) == 1 + assert config.enrollment_credentials[0].principal_id == "installer-a" + assert "one-time-enrollment-secret" not in repr(config) + + @pytest.mark.parametrize( "environment,database_url", [("invalid", "sqlite:///test.db"), ("local", "mysql://db/cloud")], @@ -97,13 +111,17 @@ def test_load_control_config_rejects_missing_production_credentials() -> None: "CLOUD_HOST_CREDENTIALS_JSON", '[{"principal_id":"agent","token":"secret","scopes":[]}]', ), + ( + "CLOUD_ENROLLMENT_TOKENS_JSON", + '[{"principal_id":"installer","token":123}]', + ), ], ) def test_load_control_config_rejects_invalid_credentials( name: str, value: str, ) -> None: - with pytest.raises(CloudConfigurationError, match="credentials") as error: + with pytest.raises(CloudConfigurationError, match="credential") as error: load_control_config({name: value}) assert "secret" not in str(error.value) diff --git a/tests/test_cloud_migrations.py b/tests/test_cloud_migrations.py index 9c3ea1e..fa8d859 100644 --- a/tests/test_cloud_migrations.py +++ b/tests/test_cloud_migrations.py @@ -29,12 +29,23 @@ def test_forward_and_downgrade_migrations_on_empty_database(tmp_path) -> None: table_names = set(inspect(engine).get_table_names()) assert { "host_registrations", + "device_enrollments", "pooled_devices", "scheduled_tasks", "plugins", "task_attempts", } <= table_names assert current_revision(database_url) == HEAD_REVISION + host_columns = { + column["name"] + for column in inspect(engine).get_columns("host_registrations") + } + assert { + "agent_instance_id", + "credential_digest", + "enrollment_token_digest", + "revoked_at", + } <= host_columns finally: engine.dispose() @@ -43,6 +54,7 @@ def test_forward_and_downgrade_migrations_on_empty_database(tmp_path) -> None: engine = create_engine(database_url) try: inspector = inspect(engine) + assert "device_enrollments" not in inspector.get_table_names() assert "task_attempts" not in inspector.get_table_names() task_columns = { column["name"] for column in inspector.get_columns("scheduled_tasks") @@ -95,6 +107,14 @@ def test_legacy_data_survives_upgrade_and_downgrade(tmp_path) -> None: column["name"] for column in inspect(engine).get_columns("scheduled_tasks") } assert {"attempt_count", "lease_id", "result_json"} <= task_columns + host_columns = { + column["name"] + for column in inspect(engine).get_columns("host_registrations") + } + assert {"agent_instance_id", "credential_digest", "revoked_at"} <= ( + host_columns + ) + assert "device_enrollments" in inspect(engine).get_table_names() finally: engine.dispose() @@ -114,6 +134,40 @@ def test_legacy_data_survives_upgrade_and_downgrade(tmp_path) -> None: engine.dispose() +def test_enrollment_downgrade_to_revision_0001_preserves_legacy_state( + tmp_path, +) -> None: + database_url = _database_url(tmp_path) + upgrade_database(database_url) + engine = create_engine(database_url) + try: + with engine.begin() as connection: + connection.execute( + text( + "insert into host_registrations " + "(host_id, address, last_seen_at) values " + "('legacy-host', null, '2026-01-01T00:00:00+00:00')" + ) + ) + finally: + engine.dispose() + + downgrade_database(database_url, "0001_cloud_repository") + + engine = create_engine(database_url) + try: + inspector = inspect(engine) + assert "device_enrollments" not in inspector.get_table_names() + assert connection_scalar(engine, "select count(*) from host_registrations") == 1 + host_columns = { + column["name"] for column in inspector.get_columns("host_registrations") + } + assert "credential_digest" not in host_columns + assert current_revision(database_url) == "0001_cloud_repository" + finally: + engine.dispose() + + def test_schema_readiness_requires_head_revision(tmp_path) -> None: database_url = _database_url(tmp_path) @@ -146,3 +200,8 @@ def _create_legacy_schema(connection) -> None: "name text primary key, version text not null, entry_point_kind text not null, " "target text not null, wired integer not null)" ) + + +def connection_scalar(engine, statement: str): + with engine.connect() as connection: + return connection.scalar(text(statement)) diff --git a/tests/test_cloud_repository_contract.py b/tests/test_cloud_repository_contract.py index 3ff10c4..d93e0b6 100644 --- a/tests/test_cloud_repository_contract.py +++ b/tests/test_cloud_repository_contract.py @@ -14,7 +14,14 @@ from cloud.database import CloudDatabase from cloud.db_models import TaskAttemptRow from cloud.plugins import PluginManifest from cloud.pool import PooledDevice -from cloud.repository import CloudRepository, LeasedAssignment, TaskAttemptRecord +from cloud.repository import ( + CloudRepository, + DeviceEnrollmentConflictError, + EnrollmentTokenConflictError, + HostEnrollmentConflictError, + LeasedAssignment, + TaskAttemptRecord, +) from cloud.scheduler import ScheduledTask, TaskConstraints @@ -53,6 +60,13 @@ def test_cloud_repository_exposes_crud_and_atomic_lease_operations() -> None: members = get_protocol_members(CloudRepository) assert { + "enroll_host", + "authenticate_enrolled_host", + "revoke_enrolled_host", + "is_enrollment_managed_host", + "enroll_device", + "get_device_enrollment", + "list_device_enrollments", "upsert_host", "replace_host_devices", "list_hosts", @@ -76,6 +90,134 @@ def test_repository_transfer_records_are_immutable() -> None: assert LeasedAssignment.__dataclass_params__.frozen is True +def test_host_enrollment_is_idempotent_and_token_is_one_time( + database_url: str, +) -> None: + database = CloudDatabase(database_url) + repository = database.repository + enrolled_at = datetime(2026, 7, 13, 4, 0, tzinfo=UTC) + host_id = _unique_id("managed-host") + agent_instance_id = _unique_id("agent-instance") + credential_digest = _unique_id("credential-digest") + enrollment_digest = _unique_id("enrollment-digest") + + try: + created = repository.enroll_host( + host_id=host_id, + agent_instance_id=agent_instance_id, + credential_digest=credential_digest, + enrollment_token_digest=enrollment_digest, + display_name="Edge Mac", + enrolled_at=enrolled_at, + ) + assert created.host_id == host_id + assert created.agent_instance_id == agent_instance_id + assert created.display_name == "Edge Mac" + assert created.enrolled_at == enrolled_at + assert repository.is_enrollment_managed_host(host_id) is True + assert repository.authenticate_enrolled_host(credential_digest) == host_id + + retried = repository.enroll_host( + host_id=_unique_id("ignored-host"), + agent_instance_id=agent_instance_id, + credential_digest=credential_digest, + enrollment_token_digest=enrollment_digest, + display_name="Renamed Edge Mac", + enrolled_at=enrolled_at + timedelta(minutes=1), + ) + assert retried == created + + with pytest.raises(HostEnrollmentConflictError): + repository.enroll_host( + host_id=_unique_id("host"), + agent_instance_id=agent_instance_id, + credential_digest=_unique_id("different-credential"), + enrollment_token_digest=enrollment_digest, + display_name=None, + enrolled_at=enrolled_at, + ) + + with pytest.raises(EnrollmentTokenConflictError): + repository.enroll_host( + host_id=_unique_id("host"), + agent_instance_id=_unique_id("different-instance"), + credential_digest=_unique_id("credential"), + enrollment_token_digest=enrollment_digest, + display_name=None, + enrolled_at=enrolled_at, + ) + + assert repository.revoke_enrolled_host( + host_id, + revoked_at=enrolled_at + timedelta(hours=1), + ) + assert repository.authenticate_enrolled_host(credential_digest) is None + assert repository.get_host(host_id) is not None + finally: + database.close() + + +def test_device_enrollment_is_host_scoped_and_idempotent( + database_url: str, +) -> None: + database = CloudDatabase(database_url) + repository = database.repository + enrolled_at = datetime(2026, 7, 13, 5, 0, tzinfo=UTC) + host_a = _unique_id("host-a") + host_b = _unique_id("host-b") + local_device_id = _unique_id("local-device") + + try: + device_a = repository.enroll_device( + device_id=_unique_id("cloud-device"), + host_id=host_a, + local_device_id=local_device_id, + driver_type="wda", + name="iPhone", + capability_tags=["ios"], + enrolled_at=enrolled_at, + ) + retried = repository.enroll_device( + device_id=_unique_id("ignored-device"), + host_id=host_a, + local_device_id=local_device_id, + driver_type="wda", + name="Renamed iPhone", + capability_tags=["ios", "physical"], + enrolled_at=enrolled_at + timedelta(minutes=1), + ) + assert retried.device_id == device_a.device_id + assert retried.name == "Renamed iPhone" + assert retried.capability_tags == ["ios", "physical"] + assert repository.get_device_enrollment(device_a.device_id) == retried + assert repository.list_device_enrollments(host_a) == [retried] + + device_b = repository.enroll_device( + device_id=_unique_id("cloud-device"), + host_id=host_b, + local_device_id=local_device_id, + driver_type="wda", + name="Moved iPhone", + capability_tags=[], + enrolled_at=enrolled_at, + ) + assert device_b.device_id != device_a.device_id + assert device_b.host_id == host_b + + with pytest.raises(DeviceEnrollmentConflictError): + repository.enroll_device( + device_id=_unique_id("device"), + host_id=host_a, + local_device_id=local_device_id, + driver_type="android", + name=None, + capability_tags=[], + enrolled_at=enrolled_at, + ) + finally: + database.close() + + def test_repository_crud_contract(database_url: str) -> None: database = CloudDatabase(database_url) repository = database.repository diff --git a/tests/test_deployment_config.py b/tests/test_deployment_config.py index e172b7b..e7696eb 100644 --- a/tests/test_deployment_config.py +++ b/tests/test_deployment_config.py @@ -32,6 +32,15 @@ def test_compose_defines_database_control_plane_and_outbound_host_agent() -> Non assert services["host-agent"]["environment"]["AI_PLANNER_ENABLED"] == ( "${AI_PLANNER_ENABLED:-false}" ) + assert ( + services["cloud-api"]["environment"]["CLOUD_ENROLLMENT_TOKENS_JSON"] + == "${CLOUD_ENROLLMENT_TOKENS_JSON:-[]}" + ) + assert ( + services["host-agent"]["environment"]["HOST_AGENT_IDENTITY_PATH"] + == "${HOST_AGENT_IDENTITY_PATH:-/app/tasks/host_identity.json}" + ) + assert "ports" not in services["host-agent"] def test_container_uses_locked_workspace_install_and_migrations() -> None: @@ -54,8 +63,11 @@ def test_example_environment_contains_only_placeholder_credentials() -> None: public_credentials = json.loads(values["CLOUD_PUBLIC_CREDENTIALS_JSON"]) host_credentials = json.loads(values["CLOUD_HOST_CREDENTIALS_JSON"]) + enrollment_credentials = json.loads(values["CLOUD_ENROLLMENT_TOKENS_JSON"]) assert public_credentials[0]["token"].startswith("change-me-") assert host_credentials[0]["token"] == values["HOST_AGENT_TOKEN"] assert host_credentials[0]["token"].startswith("change-me-") assert host_credentials[0]["host_id"] == values["HOST_AGENT_HOST_ID"] + assert enrollment_credentials[0]["token"].startswith("change-me-") + assert values["HOST_AGENT_IDENTITY_PATH"] == "/app/tasks/host_identity.json" diff --git a/tests/test_device_config.py b/tests/test_device_config.py index 3164b0b..a570f23 100644 --- a/tests/test_device_config.py +++ b/tests/test_device_config.py @@ -27,6 +27,7 @@ def test_device_config_store_add_remove_list_and_get(tmp_path) -> None: "server_url": "http://127.0.0.1:4723", "udid": "abc123", }, + "cloud_device_id": None, } assert [config["device_id"] for config in store.list()] == ["iphone-1", "iphone-2"] @@ -56,3 +57,29 @@ def test_device_config_store_unknown_device_remove_is_noop(tmp_path) -> None: store.remove("missing") assert store.get("missing") is None + + +def test_device_config_store_persists_cloud_mapping_without_losing_it_on_update( + tmp_path, +) -> None: + store = DeviceConfigStore(tmp_path / "device_config.sqlite3") + store.add( + device_id="iphone-local", + name="iPhone", + driver_type="wda", + connection_info={"udid": "abc"}, + ) + + assert store.set_cloud_device_id("iphone-local", "device-cloud-a") is True + store.add( + device_id="iphone-local", + name="Renamed iPhone", + driver_type="wda", + connection_info={"udid": "abc", "wda_local_port": 8101}, + ) + + config = store.get("iphone-local") + assert config is not None + assert config["cloud_device_id"] == "device-cloud-a" + assert config["name"] == "Renamed iPhone" + assert store.set_cloud_device_id("missing", "device-cloud-b") is False