diff --git a/.env.example b/.env.example index bbb49cf..e094eff 100644 --- a/.env.example +++ b/.env.example @@ -8,46 +8,3 @@ POSTGRES_USER=device_cloud 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"}] -# Zero-token self-service Host enrollment (see docs/CLOUD_DEPLOYMENT.md). Only -# enable this on a deployment whose control-plane URL is not reachable by -# untrusted networks; any caller that reaches it can register itself as a Host. -CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED=false -CLOUD_SCHEDULER_INTERVAL_SECONDS=1 -CLOUD_LEASE_REAPER_INTERVAL_SECONDS=5 -CLOUD_LEASE_DURATION_SECONDS=60 -CLOUD_MAX_TASK_ATTEMPTS=3 -# Browser user sessions are secure-by-default in production. Terminate TLS at a -# reverse proxy before exposing the Console; do not put initial-user passwords here. -CLOUD_USER_SESSION_IDLE_SECONDS=28800 -CLOUD_USER_SESSION_ABSOLUTE_SECONDS=604800 -CLOUD_LOGIN_FAILURE_LIMIT=5 -CLOUD_LOGIN_FAILURE_WINDOW_SECONDS=900 -CLOUD_LOGIN_BLOCK_SECONDS=900 -CLOUD_SESSION_COOKIE_SECURE=true -CLOUD_TRUST_PROXY_HEADERS=false - -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 -# One-time local operator account gate; run `device-host-agent setup` -# interactively before the daemon's first unattended start (see -# docs/CLOUD_DEPLOYMENT.md). -HOST_AGENT_LOCAL_ACCOUNT_PATH=/app/tasks/host_local_account.json -HOST_AGENT_DISPLAY_NAME= -HOST_AGENT_TASKS_PATH=./tasks -HOST_AGENT_HEARTBEAT_INTERVAL_SECONDS=30 -HOST_AGENT_POLL_TIMEOUT_SECONDS=20 -HOST_AGENT_RETRY_BACKOFF_SECONDS=1 -HOST_AGENT_MAX_RETRY_BACKOFF_SECONDS=30 -HOST_AGENT_MAX_RETRY_ATTEMPTS=5 - -AI_PLANNER_ENABLED=false -AI_PLANNER_PROVIDER=anthropic -AI_PLANNER_MODEL= -AI_PLANNER_TIMEOUT_SECONDS=30 -ANTHROPIC_API_KEY= -OPENAI_API_KEY= diff --git a/.gitignore b/.gitignore index 4fa252d..b7971c7 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,7 @@ __pycache__/ tasks/ + +*.egg-info/ +*.sqlite +*.sqlite3 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 91102bc..8ad0d49 100644 --- a/Dockerfile +++ b/Dockerfile @@ -54,6 +54,7 @@ ARG UV_INDEX_URL= ENV UV_INDEX_URL=${UV_INDEX_URL} RUN uv sync --locked --all-packages --no-dev -ENV PATH="/app/.venv/bin:$PATH" +ENV PATH="/app/.venv/bin:$PATH" \ + CLOUD_CONSOLE_STATIC_DIR=/app/console-static CMD ["device-cloud-api", "--host", "0.0.0.0", "--port", "8001"] diff --git a/apps/cloud-api/cloud_api/app.py b/apps/cloud-api/cloud_api/app.py index 3e270e1..4499d10 100644 --- a/apps/cloud-api/cloud_api/app.py +++ b/apps/cloud-api/cloud_api/app.py @@ -18,10 +18,7 @@ from starlette.types import Scope from cloud.auth import ( ChainedAuthProvider, - ChainedEnrollmentAuthProvider, - ConfiguredEnrollmentTokenProvider, RepositoryHostAuthProvider, - SelfServiceEnrollmentAuthProvider, UserSessionAuthProvider, create_auth_provider, ) @@ -110,7 +107,6 @@ def create_app( validate_control_config(control_config) build_database = database_factory or _default_database_factory configured_auth_provider = create_auth_provider( - control_config.credentials, allow_insecure_anonymous=control_config.allow_insecure_anonymous, ) repository = _RepositoryProxy() @@ -132,18 +128,8 @@ def create_app( auth_provider = ChainedAuthProvider( ( configured_auth_provider, - UserSessionAuthProvider(user_auth_service), RepositoryHostAuthProvider(repository), # type: ignore[arg-type] - ) - ) - enrollment_auth_provider = ChainedEnrollmentAuthProvider( - ( - ConfiguredEnrollmentTokenProvider(control_config.enrollment_credentials), - *( - (SelfServiceEnrollmentAuthProvider(),) - if control_config.self_service_enrollment_enabled - else () - ), + UserSessionAuthProvider(user_auth_service), ) ) domain_config = CloudConfig( @@ -306,7 +292,6 @@ 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 0f12760..4dc1fa7 100644 --- a/apps/cloud-api/tests/test_app.py +++ b/apps/cloud-api/tests/test_app.py @@ -9,7 +9,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.auth import digest_token from cloud.control_config import CloudConfigurationError, CloudControlConfig from cloud.database import CloudDatabase from cloud.pool import PooledDevice @@ -33,24 +33,8 @@ 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, - ), - ), - ) + config = CloudControlConfig(database_url=database_url) enrollment_payload = { "agent_instance_id": "agent-instance-a", "host_token": host_token, @@ -61,7 +45,6 @@ def test_managed_host_enrollment_device_mapping_and_restart_authentication( 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 @@ -70,21 +53,21 @@ def test_managed_host_enrollment_device_mapping_and_restart_authentication( 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( + another_host = client.post( "/internal/v1/enrollments", - headers={"Authorization": f"Bearer {enrollment_token}"}, json={ **enrollment_payload, "agent_instance_id": "agent-instance-b", + "host_token": "host-token-" + ("y" * 40), }, ) - assert reused.status_code == 409 + assert another_host.status_code == 201 + assert another_host.json()["host_id"] != host_id device = client.post( f"/internal/v1/hosts/{host_id}/devices/enroll", @@ -601,14 +584,16 @@ def _wait_until(predicate, timeout_seconds: float = 1.0) -> bool: return False -def test_production_app_rejects_missing_credentials() -> None: - with pytest.raises(CloudConfigurationError, match="credential"): - create_app( - config=CloudControlConfig( - environment="production", - database_url="postgresql://db/cloud", - ) +def test_production_app_allows_no_static_credentials() -> None: + app = create_app( + config=CloudControlConfig( + environment="production", + database_url="postgresql://db/cloud", + session_cookie_secure=True, ) + ) + + assert app.title == "Device Cloud API" def test_cors_headers_are_absent_when_allow_list_is_empty() -> None: diff --git a/apps/device-host-agent/host_agent/app.py b/apps/device-host-agent/host_agent/app.py index 80ac75e..0bde09f 100644 --- a/apps/device-host-agent/host_agent/app.py +++ b/apps/device-host-agent/host_agent/app.py @@ -4,18 +4,25 @@ import asyncio from contextlib import suppress from dataclasses import dataclass +import uvicorn + 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, HostAgentEnrollmentClient from host_agent.config import HostAgentConfig, load_host_agent_config +from host_agent.devices import register_local_device 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.history import ConsoleHistoryStore from host_agent.identity import HostIdentityStore from host_agent.lease import ActiveAssignmentRunner +from host_agent.local_account import LocalAccountStore from host_agent.processor import AssignmentProcessingResult, AssignmentProcessor +from host_agent.status import AgentStatusTracker +from host_agent.web.app import create_console_app +from host_agent.web.auth import SessionManager from storage.device_config import DeviceConfigStore @@ -24,6 +31,8 @@ class HostAgentApplication: client: HostAgentClient heartbeat: HeartbeatSynchronizer processor: AssignmentProcessor + console_server: uvicorn.Server | None = None + console_enrollment_client: HostAgentEnrollmentClient | None = None def run(self) -> None: asyncio.run(self.run_async()) @@ -32,6 +41,11 @@ class HostAgentApplication: stop_requested = stop or asyncio.Event() heartbeat_stop = asyncio.Event() heartbeat_task = asyncio.create_task(self.heartbeat.run(heartbeat_stop)) + console_task = ( + asyncio.create_task(self.console_server.serve()) + if self.console_server is not None + else None + ) active_processing: asyncio.Task[AssignmentProcessingResult] | None = None try: while not stop_requested.is_set(): @@ -60,11 +74,18 @@ class HostAgentApplication: with suppress(Exception): await asyncio.shield(active_processing) heartbeat_stop.set() + if self.console_server is not None: + self.console_server.should_exit = True try: await asyncio.gather(heartbeat_task, return_exceptions=True) with suppress(Exception): await self.heartbeat.sync_once() + if console_task is not None: + with suppress(asyncio.CancelledError): + await asyncio.gather(console_task, return_exceptions=True) finally: + if self.console_enrollment_client is not None: + self.console_enrollment_client.close() await self.client.aclose() async def _claim_until_stopped( @@ -104,13 +125,15 @@ def create_application( ) -> HostAgentApplication: startup_config = config or load_host_agent_config() config_store = device_config_store or DeviceConfigStore() + resolved_identity_store = identity_store or HostIdentityStore( + startup_config.identity_path + ) 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), + identity_store=resolved_identity_store, client=bootstrap_client, ) bootstrap_client.config = resolved_config @@ -123,13 +146,93 @@ def create_application( if owned_enrollment_client: bootstrap_client.close() client = HostAgentClient(resolved_config) - heartbeat = HeartbeatSynchronizer(resolved_manager, client, resolved_config) + + history_store: ConsoleHistoryStore | None = None + status_tracker: AgentStatusTracker | None = None + console_server: uvicorn.Server | None = None + console_enrollment_client: HostAgentEnrollmentClient | None = None + if resolved_config.console_enabled: + history_store = ConsoleHistoryStore( + resolved_config.identity_path.parent / "host_console_history.sqlite3", + limit=resolved_config.console_history_limit, + ) + status_tracker = AgentStatusTracker() + if resolved_config.enrollment_managed: + console_enrollment_client = HostAgentEnrollmentClient(resolved_config) + console_app = create_console_app( + config=resolved_config, + manager=resolved_manager, + config_store=config_store, + local_account_store=LocalAccountStore(resolved_config.local_account_path), + identity_store=resolved_identity_store, + history_store=history_store, + status_tracker=status_tracker, + session_manager=SessionManager( + ttl_seconds=resolved_config.console_session_ttl_seconds + ), + enrollment_client=console_enrollment_client, + ) + console_server = uvicorn.Server( + uvicorn.Config( + console_app, + host=resolved_config.console_bind_host, + port=resolved_config.console_port, + log_level="warning", + ) + ) + + heartbeat = HeartbeatSynchronizer( + resolved_manager, + client, + resolved_config, + status_tracker=status_tracker, + on_sync=( + ( + lambda device_count: history_store.record_heartbeat( + device_count=device_count + ) + ) + if history_store is not None + else None + ), + ) executor = AssignmentExecutor(create_execution_factories(resolved_manager)) active_runner = ActiveAssignmentRunner(client, executor) + processor = AssignmentProcessor( + client, + active_runner, + status_tracker=status_tracker, + on_result=( + ( + lambda assignment, result: _record_assignment_history( + history_store, assignment, result + ) + ) + if history_store is not None + else None + ), + ) return HostAgentApplication( client=client, heartbeat=heartbeat, - processor=AssignmentProcessor(client, active_runner), + processor=processor, + console_server=console_server, + console_enrollment_client=console_enrollment_client, + ) + + +def _record_assignment_history( + history_store: ConsoleHistoryStore, + assignment: AssignmentModel, + result: AssignmentProcessingResult, +) -> None: + status = "done" if result.execution.status == "done" else "failed" + history_store.record_assignment( + task_id=assignment.task_id, + attempt=assignment.attempt, + status=status, + failure_reason=result.execution.failure_reason if status == "failed" else None, + device_id=assignment.device_id, ) @@ -141,24 +244,14 @@ def _configured_device_manager( ) -> DeviceManager: manager = DeviceManager() 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( - runtime_device_id, - build_driver_factory( - device["driver_type"], - device["connection_info"], - ), - name=device["name"], + register_local_device( + config_store, + manager, + device_id=device["device_id"], driver_type=device["driver_type"], connection_info=device["connection_info"], + name=device["name"], + config=config, + enrollment_client=enrollment_client, ) return manager diff --git a/apps/device-host-agent/host_agent/client.py b/apps/device-host-agent/host_agent/client.py index 1f39da1..77e5a46 100644 --- a/apps/device-host-agent/host_agent/client.py +++ b/apps/device-host-agent/host_agent/client.py @@ -56,7 +56,7 @@ class HostAgentEnrollmentClient: response = self._request( "POST", "/internal/v1/enrollments", - token=self.config.enrollment_token or None, + token=None, json={ "agent_instance_id": agent_instance_id, "host_token": host_token, diff --git a/apps/device-host-agent/host_agent/config.py b/apps/device-host-agent/host_agent/config.py index e1badec..d9931bd 100644 --- a/apps/device-host-agent/host_agent/config.py +++ b/apps/device-host-agent/host_agent/config.py @@ -11,12 +11,14 @@ class HostAgentConfigurationError(ValueError): """Raised when Host Agent process configuration is invalid.""" +_LOOPBACK_BIND_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"}) + + @dataclass(frozen=True) class HostAgentConfig: control_plane_url: 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") local_account_path: Path = Path("tasks/host_local_account.json") enrollment_managed: bool = False @@ -26,6 +28,12 @@ class HostAgentConfig: retry_backoff_seconds: float = 1.0 max_retry_backoff_seconds: float = 30.0 max_retry_attempts: int = 5 + console_enabled: bool = False + console_bind_host: str = "127.0.0.1" + console_port: int = 8765 + console_allow_non_loopback: bool = False + console_session_ttl_seconds: float = 43200.0 + console_history_limit: int = 200 def load_host_agent_config( @@ -46,13 +54,6 @@ def load_host_agent_config( "HOST_AGENT_CONTROL_PLANE_URL must be an HTTP(S) URL" ) - host_id = values.get("HOST_AGENT_HOST_ID", "").strip() - token = values.get("HOST_AGENT_TOKEN", "").strip() - 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() ) @@ -64,12 +65,9 @@ def load_host_agent_config( config = HostAgentConfig( control_plane_url=control_plane_url, - host_id=host_id, - token=token, - enrollment_token=enrollment_token, identity_path=identity_path, local_account_path=local_account_path, - enrollment_managed=not bool(host_id), + enrollment_managed=True, display_name=values.get("HOST_AGENT_DISPLAY_NAME") or None, heartbeat_interval_seconds=_positive_float( values, @@ -96,11 +94,38 @@ def load_host_agent_config( "HOST_AGENT_MAX_RETRY_ATTEMPTS", 5, ), + console_enabled=_truthy(values, "HOST_AGENT_CONSOLE_ENABLED", False), + console_bind_host=values.get( + "HOST_AGENT_CONSOLE_BIND_HOST", "127.0.0.1" + ).strip(), + console_port=_positive_int(values, "HOST_AGENT_CONSOLE_PORT", 8765), + console_allow_non_loopback=_truthy( + values, "HOST_AGENT_CONSOLE_ALLOW_NON_LOOPBACK", False + ), + console_session_ttl_seconds=_positive_float( + values, + "HOST_AGENT_CONSOLE_SESSION_TTL_SECONDS", + 43200.0, + ), + console_history_limit=_positive_int( + values, + "HOST_AGENT_CONSOLE_HISTORY_LIMIT", + 200, + ), ) if config.max_retry_backoff_seconds < config.retry_backoff_seconds: raise HostAgentConfigurationError( "maximum retry backoff must not be less than initial backoff" ) + if config.console_enabled and ( + config.console_bind_host not in _LOOPBACK_BIND_HOSTS + and not config.console_allow_non_loopback + ): + raise HostAgentConfigurationError( + "HOST_AGENT_CONSOLE_BIND_HOST must be a loopback address " + f"({', '.join(sorted(_LOOPBACK_BIND_HOSTS))}) unless " + "HOST_AGENT_CONSOLE_ALLOW_NON_LOOPBACK is set" + ) return config @@ -136,3 +161,14 @@ def _positive_int( if value <= 0: raise HostAgentConfigurationError(f"{name} must be greater than zero") return value + + +def _truthy( + values: Mapping[str, str], + name: str, + default: bool, +) -> bool: + raw_value = values.get(name) + if raw_value is None: + return default + return raw_value.strip().lower() in {"true", "1"} diff --git a/apps/device-host-agent/host_agent/devices.py b/apps/device-host-agent/host_agent/devices.py new file mode 100644 index 0000000..798b9bb --- /dev/null +++ b/apps/device-host-agent/host_agent/devices.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from typing import Any + +from device.manager import DeviceManager +from driver.registry import build_driver_factory +from host_agent.client import HostAgentEnrollmentClient +from host_agent.config import HostAgentConfig +from storage.device_config import DeviceConfigStore + + +def register_local_device( + config_store: DeviceConfigStore, + manager: DeviceManager, + *, + device_id: str, + driver_type: str, + connection_info: dict[str, Any], + name: str | None, + config: HostAgentConfig, + enrollment_client: HostAgentEnrollmentClient | None, +) -> None: + previous = config_store.get(device_id) + previous_runtime_id = ( + previous["cloud_device_id"] or previous["device_id"] if previous else None + ) + config_store.add( + device_id=device_id, + name=name, + driver_type=driver_type, + connection_info=connection_info, + ) + runtime_device_id = device_id + if config.enrollment_managed: + assert enrollment_client is not None + enrollment = enrollment_client.enroll_device( + local_device_id=device_id, + driver_type=driver_type, + name=name, + capability_tags=[], + ) + runtime_device_id = enrollment.device_id + config_store.set_cloud_device_id(device_id, runtime_device_id) + if previous_runtime_id is not None and previous_runtime_id != runtime_device_id: + manager.unregister_device(previous_runtime_id) + manager.register_device( + runtime_device_id, + build_driver_factory(driver_type, connection_info), + name=name, + driver_type=driver_type, + connection_info=connection_info, + ) + + +def unregister_local_device( + config_store: DeviceConfigStore, + manager: DeviceManager, + *, + device_id: str, +) -> None: + record = config_store.get(device_id) + if record is None: + return + runtime_device_id = record["cloud_device_id"] or record["device_id"] + manager.unregister_device(runtime_device_id) + config_store.remove(device_id) diff --git a/apps/device-host-agent/host_agent/heartbeat.py b/apps/device-host-agent/host_agent/heartbeat.py index d3c249c..1fadf7b 100644 --- a/apps/device-host-agent/host_agent/heartbeat.py +++ b/apps/device-host-agent/host_agent/heartbeat.py @@ -8,6 +8,7 @@ from core.errors import DeviceRuntimeError from device.manager import DeviceManager from host_agent.client import HostAgentClient from host_agent.config import HostAgentConfig +from host_agent.status import AgentStatusTracker if TYPE_CHECKING: from collections.abc import Awaitable, Callable @@ -34,18 +35,31 @@ class HeartbeatSynchronizer: *, address: str | None = None, sleep: Callable[[float], Awaitable[None]] = asyncio.sleep, + status_tracker: AgentStatusTracker | None = None, + on_sync: Callable[[int], None] | None = None, ) -> None: self.manager = manager self.client = client self.config = config self.address = address self._sleep = sleep + self.status_tracker = status_tracker + self.on_sync = on_sync async def sync_once(self) -> HeartbeatResponse: - return await self.client.heartbeat( - build_device_snapshot(self.manager), + snapshot = build_device_snapshot(self.manager) + response = await self.client.heartbeat( + snapshot, address=self.address, ) + if self.status_tracker is not None: + self.status_tracker.mark_heartbeat(ok=True, device_count=len(snapshot)) + if self.on_sync is not None: + try: + self.on_sync(len(snapshot)) + except Exception: + pass + return response async def run(self, stop: asyncio.Event) -> None: self.connect_devices() diff --git a/apps/device-host-agent/host_agent/history.py b/apps/device-host-agent/host_agent/history.py new file mode 100644 index 0000000..8ea35df --- /dev/null +++ b/apps/device-host-agent/host_agent/history.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import json +import sqlite3 +from collections.abc import Callable +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + + +class ConsoleHistoryStore: + def __init__( + self, + db_path: str | Path = "tasks/host_console_history.sqlite3", + *, + limit: int = 200, + now: Callable[[], datetime] | None = None, + ) -> None: + self.db_path = Path(db_path) + self.limit = limit + self._now = now or (lambda: datetime.now(UTC)) + self.db_path.parent.mkdir(parents=True, exist_ok=True) + self._ensure_schema() + + def record_assignment( + self, + *, + task_id: str, + attempt: int, + status: str, + failure_reason: str | None, + device_id: str, + ) -> None: + summary = f"{task_id} attempt {attempt} on {device_id}: {status}" + if failure_reason: + summary = f"{summary} ({failure_reason})" + detail = { + "task_id": task_id, + "attempt": attempt, + "status": status, + "failure_reason": failure_reason, + "device_id": device_id, + } + self._insert("assignment", summary, detail) + + def record_heartbeat(self, *, device_count: int) -> None: + summary = f"heartbeat: {device_count} devices" + detail = {"device_count": device_count} + self._insert("heartbeat", summary, detail) + + def list_recent(self, limit: int | None = None) -> list[dict[str, Any]]: + effective_limit = limit if limit is not None else self.limit + with self._connect() as connection: + rows = connection.execute( + "select kind, occurred_at, summary, detail_json" + " from history_entries order by id desc limit ?", + (effective_limit,), + ).fetchall() + return [ + { + "kind": row["kind"], + "occurred_at": row["occurred_at"], + "summary": row["summary"], + "detail": json.loads(row["detail_json"]), + } + for row in rows + ] + + def _insert(self, kind: str, summary: str, detail: dict[str, Any]) -> None: + occurred_at = self._now().isoformat() + with self._connect() as connection: + connection.execute( + """ + insert into history_entries (kind, occurred_at, summary, detail_json) + values (?, ?, ?, ?) + """, + (kind, occurred_at, summary, json.dumps(detail, ensure_ascii=False)), + ) + connection.execute( + """ + delete from history_entries where id not in ( + select id from history_entries order by id desc limit ? + ) + """, + (self.limit,), + ) + + def _ensure_schema(self) -> None: + with self._connect() as connection: + connection.execute( + """ + create table if not exists history_entries ( + id integer primary key autoincrement, + kind text not null, + occurred_at text not null, + summary text not null, + detail_json text not null + ) + """ + ) + + def _connect(self) -> sqlite3.Connection: + connection = sqlite3.connect(self.db_path) + connection.row_factory = sqlite3.Row + return connection diff --git a/apps/device-host-agent/host_agent/processor.py b/apps/device-host-agent/host_agent/processor.py index adb1e3e..d17f4e2 100644 --- a/apps/device-host-agent/host_agent/processor.py +++ b/apps/device-host-agent/host_agent/processor.py @@ -1,11 +1,15 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Literal, Protocol +from typing import TYPE_CHECKING, Literal, Protocol from cloud.internal_api.models import AssignmentModel from host_agent.assignment import AssignmentExecutionResult from host_agent.client import HostAgentClient +from host_agent.status import AgentStatusTracker + +if TYPE_CHECKING: + from collections.abc import Callable class ActiveAssignmentExecutor(Protocol): @@ -25,24 +29,42 @@ class AssignmentProcessor: self, client: HostAgentClient, active_executor: ActiveAssignmentExecutor, + *, + status_tracker: AgentStatusTracker | None = None, + on_result: Callable[[AssignmentModel, AssignmentProcessingResult], None] + | None = None, ) -> None: self.client = client self.active_executor = active_executor + self.status_tracker = status_tracker + self.on_result = on_result def request_stop(self) -> None: self.active_executor.request_stop() async def process(self, assignment: AssignmentModel) -> AssignmentProcessingResult: - execution = await self.active_executor.run(assignment) - status = "done" if execution.status == "done" else "failed" - failure_reason = execution.failure_reason if status == "failed" else None - response = await self.client.report_result( - assignment, - status=status, - failure_reason=failure_reason, - result=dict(execution.metadata), - ) - return AssignmentProcessingResult( - execution=execution, - report_status=response.status, - ) + if self.status_tracker is not None: + self.status_tracker.mark_assignment_started(assignment) + try: + execution = await self.active_executor.run(assignment) + status = "done" if execution.status == "done" else "failed" + failure_reason = execution.failure_reason if status == "failed" else None + response = await self.client.report_result( + assignment, + status=status, + failure_reason=failure_reason, + result=dict(execution.metadata), + ) + result = AssignmentProcessingResult( + execution=execution, + report_status=response.status, + ) + finally: + if self.status_tracker is not None: + self.status_tracker.mark_assignment_finished() + if self.on_result is not None: + try: + self.on_result(assignment, result) + except Exception: + pass + return result diff --git a/apps/device-host-agent/host_agent/status.py b/apps/device-host-agent/host_agent/status.py new file mode 100644 index 0000000..b0815ee --- /dev/null +++ b/apps/device-host-agent/host_agent/status.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import threading +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import TYPE_CHECKING, Any + +from cloud.internal_api.models import AssignmentModel + +if TYPE_CHECKING: + from collections.abc import Callable + + +@dataclass(frozen=True) +class _CurrentAssignment: + task_id: str + device_id: str + goal: str | None + workflow_definition_id: str | None + started_at: datetime + + +@dataclass(frozen=True) +class _LastHeartbeat: + ok: bool + device_count: int + at: datetime + + +class AgentStatusTracker: + def __init__(self, *, now: Callable[[], datetime] | None = None) -> None: + self._now = now or (lambda: datetime.now(UTC)) + self._lock = threading.Lock() + self._current_assignment: _CurrentAssignment | None = None + self._last_heartbeat: _LastHeartbeat | None = None + + def mark_assignment_started(self, assignment: AssignmentModel) -> None: + with self._lock: + self._current_assignment = _CurrentAssignment( + task_id=assignment.task_id, + device_id=assignment.device_id, + goal=assignment.goal, + workflow_definition_id=assignment.workflow_definition_id, + started_at=self._now(), + ) + + def mark_assignment_finished(self) -> None: + with self._lock: + self._current_assignment = None + + def mark_heartbeat(self, *, ok: bool, device_count: int) -> None: + with self._lock: + self._last_heartbeat = _LastHeartbeat( + ok=ok, + device_count=device_count, + at=self._now(), + ) + + def snapshot(self) -> dict[str, Any]: + with self._lock: + current_assignment = self._current_assignment + last_heartbeat = self._last_heartbeat + return { + "current_assignment": ( + { + "task_id": current_assignment.task_id, + "device_id": current_assignment.device_id, + "goal": current_assignment.goal, + "workflow_definition_id": current_assignment.workflow_definition_id, + "started_at": current_assignment.started_at.isoformat(), + } + if current_assignment is not None + else None + ), + "last_heartbeat": ( + { + "ok": last_heartbeat.ok, + "device_count": last_heartbeat.device_count, + "at": last_heartbeat.at.isoformat(), + } + if last_heartbeat is not None + else None + ), + } diff --git a/apps/device-host-agent/host_agent/web/__init__.py b/apps/device-host-agent/host_agent/web/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/device-host-agent/host_agent/web/app.py b/apps/device-host-agent/host_agent/web/app.py new file mode 100644 index 0000000..44a6fb9 --- /dev/null +++ b/apps/device-host-agent/host_agent/web/app.py @@ -0,0 +1,535 @@ +from __future__ import annotations + +import asyncio +import json +from html import escape as _escape +from typing import Any + +from fastapi import Depends, FastAPI, HTTPException, Request +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response + +from device.manager import DeviceManager +from host_agent.client import HostAgentEnrollmentClient +from host_agent.config import HostAgentConfig +from host_agent.devices import register_local_device, unregister_local_device +from host_agent.history import ConsoleHistoryStore +from host_agent.identity import HostIdentityState, HostIdentityStore +from host_agent.local_account import LocalAccountState, LocalAccountStore +from host_agent.status import AgentStatusTracker +from host_agent.web.auth import ( + SessionManager, + SessionState, + attempt_login, + change_password, +) +from storage.device_config import DeviceConfigStore + +SESSION_COOKIE_NAME = "host_console_session" +CSRF_HEADER_NAME = "X-CSRF-Token" +CSRF_FORM_FIELD = "csrf_token" +_LOOPBACK_BIND_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"}) + +_CSS = """ +body { font-family: system-ui, sans-serif; margin: 0; background: #f5f5f5; color: #222; } +header { background: #20303f; color: #fff; padding: 0.75rem 1.5rem; } +header nav { display: inline; margin-left: 1.5rem; } +header nav a, header nav form { display: inline-block; margin-right: 1rem; } +header a { color: #fff; text-decoration: none; } +header button { background: none; border: none; color: #fff; text-decoration: underline; cursor: pointer; padding: 0; font: inherit; } +main { padding: 1.5rem; max-width: 960px; margin: 0 auto; } +table { border-collapse: collapse; width: 100%; margin-bottom: 1rem; background: #fff; } +th, td { border: 1px solid #ccc; padding: 0.4rem 0.6rem; text-align: left; } +form.inline { display: inline; margin: 0; } +.error { color: #b00020; } +.notice { color: #1b5e20; } +""" + + +def escape(value: object) -> str: + if value is None: + return "" + return _escape(str(value), quote=True) + + +def _chrome(title: str, body_html: str, *, session: SessionState | None) -> str: + nav = "" + if session is not None: + nav = f""" + + """ + return f""" + + + +{escape(title)} + + + +
+ Host Agent Console + {nav} +
+
+{body_html} +
+ +""" + + +def _login_page( + *, account: LocalAccountState | None, error: str | None = None +) -> HTMLResponse: + if account is None: + body = """ +

Login

+

No local account exists yet. Run device-host-agent setup + on this machine to create one before logging in to the console.

+ """ + return HTMLResponse(_chrome("Login", body, session=None)) + error_html = f'

{escape(error)}

' if error else "" + body = f""" +

Login

+ {error_html} +
+
+
+ +
+ """ + return HTMLResponse(_chrome("Login", body, session=None)) + + +def _dashboard_body( + *, + identity: HostIdentityState | None, + snapshot: dict[str, Any], + devices: list[Any], + config: HostAgentConfig, +) -> str: + heartbeat = snapshot.get("last_heartbeat") + assignment = snapshot.get("current_assignment") + heartbeat_text = ( + f"{'ok' if heartbeat['ok'] else 'failed'} at {heartbeat['at']} " + f"({heartbeat['device_count']} devices)" + if heartbeat + else "never" + ) + assignment_text = ( + f"{assignment['task_id']} on {assignment['device_id']} " + f"(started {assignment['started_at']})" + if assignment + else "none" + ) + device_rows = "".join( + f"{escape(device.id)}{escape(device.name or '')}" + f"{escape(device.driver_type)}{escape(device.status)}" + for device in devices + ) + return f""" +

Status

+
+

Enrollment

+

Host ID: {escape(identity.host_id if identity else None) or "not enrolled"}

+

Agent instance ID: {escape(identity.agent_instance_id if identity else None) or "unknown"}

+

Control plane: {escape(config.control_plane_url)}

+
+
+

Heartbeat

+

{escape(heartbeat_text)}

+
+
+

Current assignment

+

{escape(assignment_text)}

+
+
+

Devices

+ + + {device_rows} +
IDNameDriverStatus
+
+ + """ + + +def _devices_body( + *, + devices: list[dict[str, Any]], + csrf_token: str, + edit_record: dict[str, Any] | None, + error: str | None, +) -> str: + error_html = f'

{escape(error)}

' if error else "" + rows = "".join( + f""" + + {escape(device["device_id"])} + {escape(device["name"] or "")} + {escape(device["driver_type"])} + {escape(device["cloud_device_id"] or "")} + + Edit +
+ + + +
+ + + """ + for device in devices + ) + form_device_id = escape(edit_record["device_id"]) if edit_record else "" + form_name = escape(edit_record["name"] or "") if edit_record else "" + form_driver_type = escape(edit_record["driver_type"]) if edit_record else "wda" + form_connection_info = ( + escape(json.dumps(edit_record["connection_info"])) if edit_record else "{}" + ) + return f""" +

Devices

+ {error_html} + + + {rows} +
IDNameDriverCloud ID
+

{"Edit device" if edit_record else "Add device"}

+
+ +
+
+
+
+ +
+ """ + + +def _account_body(*, csrf_token: str, message: str | None, error: str | None) -> str: + message_html = f'

{escape(message)}

' if message else "" + error_html = f'

{escape(error)}

' if error else "" + return f""" +

Account

+ {message_html} + {error_html} +
+ +
+
+
+ +
+ """ + + +def _history_body(entries: list[dict[str, Any]]) -> str: + rows = "".join( + f"{escape(entry['occurred_at'])}{escape(entry['kind'])}" + f"{escape(entry['summary'])}" + for entry in entries + ) + return f""" +

History

+ + + {rows} +
TimeKindSummary
+ """ + + +def create_console_app( + *, + config: HostAgentConfig, + manager: DeviceManager, + config_store: DeviceConfigStore, + local_account_store: LocalAccountStore, + identity_store: HostIdentityStore, + history_store: ConsoleHistoryStore, + status_tracker: AgentStatusTracker, + session_manager: SessionManager, + enrollment_client: HostAgentEnrollmentClient | None, +) -> FastAPI: + app = FastAPI(title="Host Agent Console") + cookie_secure = config.console_bind_host not in _LOOPBACK_BIND_HOSTS + + def _session_token(request: Request) -> str | None: + return request.cookies.get(SESSION_COOKIE_NAME) + + def require_session(request: Request) -> SessionState: + token = _session_token(request) + state = session_manager.validate(token) if token else None + if state is None: + raise HTTPException(status_code=303, headers={"Location": "/login"}) + return state + + async def require_csrf( + request: Request, + session: SessionState = Depends(require_session), + ) -> SessionState: + provided = request.headers.get(CSRF_HEADER_NAME) + if not provided: + form = await request.form() + raw = form.get(CSRF_FORM_FIELD) + provided = str(raw) if raw is not None else None + token = _session_token(request) + if ( + not token + or not provided + or not session_manager.validate_csrf(token, provided) + ): + raise HTTPException(status_code=403, detail="invalid CSRF token") + return session + + @app.get("/login", response_class=HTMLResponse) + async def login_page() -> HTMLResponse: + account = await asyncio.to_thread(local_account_store.load) + return _login_page(account=account) + + @app.post("/login") + async def login_submit(request: Request) -> Response: + account = await asyncio.to_thread(local_account_store.load) + if account is None: + return _login_page(account=None) + form = await request.form() + username = str(form.get("username", "")) + password = str(form.get("password", "")) + ok = await asyncio.to_thread( + attempt_login, local_account_store, username=username, password=password + ) + if not ok: + return _login_page(account=account, error="Invalid username or password.") + session_token, _ = session_manager.create_session(username) + response = RedirectResponse(url="/", status_code=303) + response.set_cookie( + key=SESSION_COOKIE_NAME, + value=session_token, + httponly=True, + samesite="strict", + secure=cookie_secure, + path="/", + ) + return response + + @app.post("/logout") + async def logout( + request: Request, + session: SessionState = Depends(require_csrf), + ) -> Response: + token = _session_token(request) + if token: + session_manager.invalidate(token) + response = RedirectResponse(url="/login", status_code=303) + response.delete_cookie(key=SESSION_COOKIE_NAME, path="/") + return response + + @app.get("/", response_class=HTMLResponse) + async def dashboard( + session: SessionState = Depends(require_session), + ) -> HTMLResponse: + identity = await asyncio.to_thread(identity_store.load) + snapshot = status_tracker.snapshot() + devices = manager.list_devices() + body = _dashboard_body( + identity=identity, snapshot=snapshot, devices=devices, config=config + ) + return HTMLResponse(_chrome("Status", body, session=session)) + + @app.get("/api/status") + async def api_status( + session: SessionState = Depends(require_session), + ) -> JSONResponse: + snapshot = status_tracker.snapshot() + devices = [ + { + "id": device.id, + "name": device.name, + "driver_type": device.driver_type, + "status": device.status, + } + for device in manager.list_devices() + ] + return JSONResponse({"status": snapshot, "devices": devices}) + + @app.get("/devices", response_class=HTMLResponse) + async def devices_page( + request: Request, + session: SessionState = Depends(require_session), + ) -> HTMLResponse: + devices = await asyncio.to_thread(config_store.list) + edit_id = request.query_params.get("edit") + edit_record = ( + await asyncio.to_thread(config_store.get, edit_id) if edit_id else None + ) + body = _devices_body( + devices=devices, + csrf_token=session.csrf_token, + edit_record=edit_record, + error=None, + ) + return HTMLResponse(_chrome("Devices", body, session=session)) + + @app.post("/devices/save") + async def devices_save( + request: Request, + session: SessionState = Depends(require_csrf), + ) -> Response: + form = await request.form() + device_id = str(form.get("device_id", "")).strip() + driver_type = str(form.get("driver_type", "")).strip() + name = str(form.get("name", "")).strip() or None + connection_info_raw = str(form.get("connection_info", "") or "{}") + + error: str | None = None + connection_info: dict[str, Any] = {} + if not device_id or not driver_type: + error = "Device ID and driver type are required." + else: + try: + parsed = json.loads(connection_info_raw) + except ValueError: + error = "Connection info must be valid JSON." + else: + if not isinstance(parsed, dict): + error = "Connection info must be a JSON object." + else: + connection_info = parsed + + if error is None: + try: + await asyncio.to_thread( + register_local_device, + config_store, + manager, + device_id=device_id, + driver_type=driver_type, + connection_info=connection_info, + name=name, + config=config, + enrollment_client=enrollment_client, + ) + except ValueError as exc: + error = str(exc) + + if error is not None: + devices = await asyncio.to_thread(config_store.list) + body = _devices_body( + devices=devices, + csrf_token=session.csrf_token, + edit_record=None, + error=error, + ) + return HTMLResponse( + _chrome("Devices", body, session=session), status_code=400 + ) + return RedirectResponse(url="/devices", status_code=303) + + @app.post("/devices/remove") + async def devices_remove( + request: Request, + session: SessionState = Depends(require_csrf), + ) -> Response: + form = await request.form() + device_id = str(form.get("device_id", "")).strip() + if device_id: + await asyncio.to_thread( + unregister_local_device, config_store, manager, device_id=device_id + ) + return RedirectResponse(url="/devices", status_code=303) + + @app.get("/account", response_class=HTMLResponse) + async def account_page( + session: SessionState = Depends(require_session), + ) -> HTMLResponse: + body = _account_body(csrf_token=session.csrf_token, message=None, error=None) + return HTMLResponse(_chrome("Account", body, session=session)) + + @app.post("/account", response_class=HTMLResponse) + async def account_submit( + request: Request, + session: SessionState = Depends(require_csrf), + ) -> HTMLResponse: + form = await request.form() + current_password = str(form.get("current_password", "")) + new_password = str(form.get("new_password", "")) + confirm_password = str(form.get("confirm_password", "")) + if not new_password or new_password != confirm_password: + body = _account_body( + csrf_token=session.csrf_token, + message=None, + error="New password and confirmation must match.", + ) + return HTMLResponse( + _chrome("Account", body, session=session), status_code=400 + ) + ok = await asyncio.to_thread( + change_password, + local_account_store, + current_password=current_password, + new_password=new_password, + ) + if not ok: + body = _account_body( + csrf_token=session.csrf_token, + message=None, + error="Current password is incorrect.", + ) + return HTMLResponse( + _chrome("Account", body, session=session), status_code=400 + ) + body = _account_body( + csrf_token=session.csrf_token, + message="Password updated.", + error=None, + ) + return HTMLResponse(_chrome("Account", body, session=session)) + + @app.get("/history", response_class=HTMLResponse) + async def history_page( + session: SessionState = Depends(require_session), + ) -> HTMLResponse: + entries = await asyncio.to_thread(history_store.list_recent) + body = _history_body(entries) + return HTMLResponse(_chrome("History", body, session=session)) + + return app diff --git a/apps/device-host-agent/host_agent/web/auth.py b/apps/device-host-agent/host_agent/web/auth.py new file mode 100644 index 0000000..5ce9e8c --- /dev/null +++ b/apps/device-host-agent/host_agent/web/auth.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import hmac +import secrets +from collections.abc import Callable +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from threading import Lock + +from host_agent.local_account import LocalAccountStore + +SESSION_TOKEN_BYTES = 32 +CSRF_TOKEN_BYTES = 32 + + +@dataclass(frozen=True) +class SessionState: + username: str + csrf_token: str + expires_at: datetime + + +class SessionManager: + def __init__( + self, + *, + ttl_seconds: float, + now: Callable[[], datetime] | None = None, + ) -> None: + self._ttl = timedelta(seconds=ttl_seconds) + self._now = now or (lambda: datetime.now(UTC)) + self._lock = Lock() + self._sessions: dict[str, SessionState] = {} + + def create_session(self, username: str) -> tuple[str, str]: + session_token = secrets.token_urlsafe(SESSION_TOKEN_BYTES) + csrf_token = secrets.token_urlsafe(CSRF_TOKEN_BYTES) + state = SessionState( + username=username, + csrf_token=csrf_token, + expires_at=self._now() + self._ttl, + ) + with self._lock: + self._sessions[session_token] = state + return session_token, csrf_token + + def validate(self, session_token: str) -> SessionState | None: + now = self._now() + with self._lock: + state = self._sessions.get(session_token) + if state is None: + return None + if state.expires_at <= now: + del self._sessions[session_token] + return None + renewed = SessionState( + username=state.username, + csrf_token=state.csrf_token, + expires_at=now + self._ttl, + ) + self._sessions[session_token] = renewed + return renewed + + def validate_csrf(self, session_token: str, csrf_token: str) -> bool: + state = self.validate(session_token) + if state is None: + return False + return hmac.compare_digest(state.csrf_token, csrf_token) + + def invalidate(self, session_token: str) -> None: + with self._lock: + self._sessions.pop(session_token, None) + + +def attempt_login(store: LocalAccountStore, *, username: str, password: str) -> bool: + account = store.load() + if account is None or account.username != username: + return False + return store.verify(account, password) + + +def change_password( + store: LocalAccountStore, *, current_password: str, new_password: str +) -> bool: + account = store.load() + if account is None or not store.verify(account, current_password): + return False + store.create(account.username, new_password) + return True diff --git a/apps/device-host-agent/pyproject.toml b/apps/device-host-agent/pyproject.toml index 4ae53e4..7f32b8f 100644 --- a/apps/device-host-agent/pyproject.toml +++ b/apps/device-host-agent/pyproject.toml @@ -6,7 +6,9 @@ requires-python = ">=3.14" dependencies = [ "device-agent-runtime==0.1.0", "device-cloud-platform==0.1.0", + "fastapi>=0.115.0", "httpx>=0.27.0", + "uvicorn[standard]>=0.30.0", ] [project.scripts] diff --git a/apps/device-host-agent/tests/test_app.py b/apps/device-host-agent/tests/test_app.py index 4ce1b4f..7bc041a 100644 --- a/apps/device-host-agent/tests/test_app.py +++ b/apps/device-host-agent/tests/test_app.py @@ -1,9 +1,12 @@ from __future__ import annotations import asyncio +import socket from contextlib import suppress from datetime import UTC, datetime, timedelta +import httpx + from cloud.internal_api.models import ( AssignmentModel, DeviceEnrollmentResponse, @@ -16,6 +19,12 @@ from host_agent.identity import HostIdentityStore from storage.device_config import DeviceConfigStore +def _free_loopback_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: + probe.bind(("127.0.0.1", 0)) + return probe.getsockname()[1] + + def _config() -> HostAgentConfig: return HostAgentConfig( control_plane_url="https://control.example", @@ -88,7 +97,6 @@ def test_create_application_enrolls_host_and_devices_before_managed_startup( def __init__(self) -> None: self.config = HostAgentConfig( control_plane_url="https://control.example", - enrollment_token="one-time-token", enrollment_managed=True, ) @@ -352,3 +360,98 @@ def test_main_task_cancellation_waits_for_active_work_shutdown() -> None: assert events == ["work-finished", "final-heartbeat", "closed"] asyncio.run(scenario()) + + +def test_console_enabled_serves_http_and_shuts_down_cleanly(tmp_path) -> None: + async def scenario() -> None: + port = _free_loopback_port() + config = HostAgentConfig( + control_plane_url="https://control.example", + host_id="host-a", + token="secret", + identity_path=tmp_path / "host_identity.json", + local_account_path=tmp_path / "host_local_account.json", + console_enabled=True, + console_bind_host="127.0.0.1", + console_port=port, + ) + application = create_application(config=config, manager=DeviceManager()) + assert application.console_server is not None + + claim_started = asyncio.Event() + claim_cancelled = asyncio.Event() + events: list[str] = [] + + class BlockingClient: + async def claim(self): + claim_started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + claim_cancelled.set() + raise + + async def aclose(self): + events.append("closed") + + class RecordingHeartbeat: + async def run(self, stop): + await stop.wait() + + async def sync_once(self): + events.append("final-heartbeat") + + class IdleProcessor: + async def process(self, assignment): + raise AssertionError("no assignment expected") + + def request_stop(self): + events.append("stop-work") + + application.client = BlockingClient() # type: ignore[assignment] + application.heartbeat = RecordingHeartbeat() # type: ignore[assignment] + application.processor = IdleProcessor() # type: ignore[assignment] + + stop = asyncio.Event() + running = asyncio.create_task(application.run_async(stop)) + await claim_started.wait() + + response: httpx.Response | None = None + async with httpx.AsyncClient() as http_client: + loop = asyncio.get_running_loop() + deadline = loop.time() + 5 + while loop.time() < deadline: + try: + response = await http_client.get( + f"http://127.0.0.1:{port}/login", timeout=0.5 + ) + except httpx.TransportError: + await asyncio.sleep(0.05) + continue + break + assert response is not None + assert response.status_code == 200 + assert "Login" in response.text + + stop.set() + await asyncio.wait_for(running, timeout=5) + + assert claim_cancelled.is_set() + assert events == ["stop-work", "final-heartbeat", "closed"] + assert application.console_server.should_exit is True + + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: + probe.settimeout(0.5) + with suppress(ConnectionRefusedError, OSError): + probe.connect(("127.0.0.1", port)) + raise AssertionError("console socket should be closed after shutdown") + + asyncio.run(scenario()) + + +def test_console_disabled_by_default_opens_no_socket(tmp_path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + application = create_application(config=_config(), manager=DeviceManager()) + + assert application.console_server is None + asyncio.run(application.client.aclose()) diff --git a/apps/device-host-agent/tests/test_client.py b/apps/device-host-agent/tests/test_client.py index 8476c4a..fbbcece 100644 --- a/apps/device-host-agent/tests/test_client.py +++ b/apps/device-host-agent/tests/test_client.py @@ -172,7 +172,7 @@ def test_result_report_retries_identical_payload_after_response_loss() -> None: assert payloads[0]["failure_reason"] == "planner unavailable" -def test_bootstrap_client_retries_identical_enrollment_and_enrolls_device() -> None: +def test_bootstrap_client_directly_enrolls_and_enrolls_device() -> None: requests: list[httpx.Request] = [] host_attempts = 0 @@ -189,7 +189,6 @@ def test_bootstrap_client_retries_identical_enrollment_and_enrolls_device() -> N config = _config( host_id="", token="", - enrollment_token="one-time-token", enrollment_managed=True, ) with httpx.Client( @@ -209,7 +208,6 @@ def test_bootstrap_client_retries_identical_enrollment_and_enrolls_device() -> N 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( @@ -223,38 +221,5 @@ def test_bootstrap_client_retries_identical_enrollment_and_enrolls_device() -> N 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)) - - -def test_self_service_enrollment_sends_no_authorization_header() -> None: - requests: list[httpx.Request] = [] - - def handler(request: httpx.Request) -> httpx.Response: - requests.append(request) - return httpx.Response(201, json={"host_id": "host-cloud-a"}) - - config = _config( - host_id="", - token="", - enrollment_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="operator", - ) - - assert host.host_id == "host-cloud-a" - assert len(requests) == 1 assert "authorization" not in requests[0].headers + 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 58ba276..245ac6f 100644 --- a/apps/device-host-agent/tests/test_config.py +++ b/apps/device-host-agent/tests/test_config.py @@ -11,24 +11,16 @@ from host_agent.config import ( ) -BASE_ENV = { - "HOST_AGENT_HOST_ID": "host-a", - "HOST_AGENT_TOKEN": "secret", -} - - def test_load_host_agent_config_uses_managed_cloud_default() -> None: - assert load_host_agent_config(BASE_ENV) == HostAgentConfig( + assert load_host_agent_config({}) == HostAgentConfig( control_plane_url="https://amcp.home.jerryyan.top", - host_id="host-a", - token="secret", + enrollment_managed=True, ) def test_load_host_agent_config_parses_poll_and_retry_values() -> None: config = load_host_agent_config( { - **BASE_ENV, "HOST_AGENT_CONTROL_PLANE_URL": "https://cloud.example/v1/", "HOST_AGENT_HEARTBEAT_INTERVAL_SECONDS": "10", "HOST_AGENT_POLL_TIMEOUT_SECONDS": "15", @@ -42,12 +34,11 @@ 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: +def test_load_host_agent_config_uses_direct_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", } @@ -55,30 +46,30 @@ def test_load_host_agent_config_supports_managed_enrollment(tmp_path) -> None: 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") +def test_static_credential_environment_variables_are_ignored() -> None: + config = load_host_agent_config( + { + "HOST_AGENT_HOST_ID": "legacy-host", + "HOST_AGENT_TOKEN": "legacy-token", + "HOST_AGENT_ENROLLMENT_TOKEN": "legacy-enrollment-token", + } + ) - config = load_host_agent_config({"HOST_AGENT_IDENTITY_PATH": str(identity_path)}) - - assert config.identity_path == Path(identity_path) + assert config.host_id == "" + assert config.token == "" assert config.enrollment_managed is True -def test_fresh_install_with_no_token_is_valid_and_defaults_local_account_path() -> None: - config = load_host_agent_config({"HOST_AGENT_CONTROL_PLANE_URL": "https://cloud.example"}) +def test_fresh_install_defaults_local_account_path() -> None: + config = load_host_agent_config( + {"HOST_AGENT_CONTROL_PLANE_URL": "https://cloud.example"} + ) - assert config.host_id == "" - assert config.enrollment_token == "" assert config.enrollment_managed is True assert config.local_account_path == Path("tasks/host_local_account.json") @@ -98,9 +89,6 @@ def test_local_account_path_can_be_overridden(tmp_path) -> None: @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"}, { @@ -113,4 +101,95 @@ def test_load_host_agent_config_rejects_invalid_values( overrides: dict[str, str], ) -> None: with pytest.raises(HostAgentConfigurationError): - load_host_agent_config({**BASE_ENV, **overrides}) + load_host_agent_config(overrides) + + +def test_console_defaults_are_disabled_and_do_not_trigger_validation() -> None: + config = load_host_agent_config({}) + + assert config.console_enabled is False + assert config.console_bind_host == "127.0.0.1" + assert config.console_port == 8765 + assert config.console_allow_non_loopback is False + assert config.console_session_ttl_seconds == 43200.0 + assert config.console_history_limit == 200 + + +def test_console_enabled_with_default_loopback_bind_passes() -> None: + config = load_host_agent_config({"HOST_AGENT_CONSOLE_ENABLED": "true"}) + + assert config.console_enabled is True + assert config.console_bind_host == "127.0.0.1" + + +@pytest.mark.parametrize("bind_host", ["127.0.0.1", "localhost", "::1"]) +def test_console_enabled_with_loopback_bind_host_passes(bind_host: str) -> None: + config = load_host_agent_config( + { + "HOST_AGENT_CONSOLE_ENABLED": "true", + "HOST_AGENT_CONSOLE_BIND_HOST": bind_host, + } + ) + + assert config.console_bind_host == bind_host + + +def test_console_enabled_with_non_loopback_bind_without_opt_in_raises() -> None: + with pytest.raises(HostAgentConfigurationError): + load_host_agent_config( + { + "HOST_AGENT_CONSOLE_ENABLED": "true", + "HOST_AGENT_CONSOLE_BIND_HOST": "0.0.0.0", + } + ) + + +def test_console_enabled_with_non_loopback_bind_with_opt_in_succeeds() -> None: + config = load_host_agent_config( + { + "HOST_AGENT_CONSOLE_ENABLED": "true", + "HOST_AGENT_CONSOLE_BIND_HOST": "0.0.0.0", + "HOST_AGENT_CONSOLE_ALLOW_NON_LOOPBACK": "true", + } + ) + + assert config.console_bind_host == "0.0.0.0" + assert config.console_allow_non_loopback is True + + +def test_console_disabled_with_non_loopback_bind_does_not_raise() -> None: + config = load_host_agent_config({"HOST_AGENT_CONSOLE_BIND_HOST": "0.0.0.0"}) + + assert config.console_enabled is False + assert config.console_bind_host == "0.0.0.0" + + +def test_console_env_vars_parse_numeric_and_bool_fields() -> None: + config = load_host_agent_config( + { + "HOST_AGENT_CONSOLE_ENABLED": "1", + "HOST_AGENT_CONSOLE_PORT": "9001", + "HOST_AGENT_CONSOLE_SESSION_TTL_SECONDS": "3600", + "HOST_AGENT_CONSOLE_HISTORY_LIMIT": "50", + } + ) + + assert config.console_enabled is True + assert config.console_port == 9001 + assert config.console_session_ttl_seconds == 3600 + assert config.console_history_limit == 50 + + +@pytest.mark.parametrize( + "overrides", + [ + {"HOST_AGENT_CONSOLE_PORT": "0"}, + {"HOST_AGENT_CONSOLE_SESSION_TTL_SECONDS": "-1"}, + {"HOST_AGENT_CONSOLE_HISTORY_LIMIT": "0"}, + ], +) +def test_console_numeric_fields_reject_invalid_values( + overrides: dict[str, str], +) -> None: + with pytest.raises(HostAgentConfigurationError): + load_host_agent_config(overrides) diff --git a/apps/device-host-agent/tests/test_devices.py b/apps/device-host-agent/tests/test_devices.py new file mode 100644 index 0000000..a19dcce --- /dev/null +++ b/apps/device-host-agent/tests/test_devices.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +from cloud.internal_api.models import DeviceEnrollmentResponse +from device.manager import DeviceManager +from host_agent.config import HostAgentConfig +from host_agent.devices import register_local_device, unregister_local_device +from storage.device_config import DeviceConfigStore + + +def _config(*, enrollment_managed: bool) -> HostAgentConfig: + return HostAgentConfig( + control_plane_url="https://control.example", + host_id="host-a", + token="secret", + enrollment_managed=enrollment_managed, + ) + + +class RecordingEnrollmentClient: + def __init__(self, device_id: str) -> None: + self.device_id = device_id + self.calls: list[dict[str, object]] = [] + + def enroll_device(self, **payload): + self.calls.append(payload) + return DeviceEnrollmentResponse(device_id=self.device_id) + + +def test_register_local_device_enrollment_managed_registers_under_cloud_id( + tmp_path, +) -> None: + store = DeviceConfigStore(tmp_path / "devices.sqlite3") + manager = DeviceManager() + enrollment_client = RecordingEnrollmentClient("device-cloud-a") + + register_local_device( + store, + manager, + device_id="local-device-a", + driver_type="wda", + connection_info={"server_url": "http://127.0.0.1:4723"}, + name="Lab iPhone", + config=_config(enrollment_managed=True), + enrollment_client=enrollment_client, # type: ignore[arg-type] + ) + + assert enrollment_client.calls == [ + { + "local_device_id": "local-device-a", + "driver_type": "wda", + "name": "Lab iPhone", + "capability_tags": [], + } + ] + assert [device.id for device in manager.list_devices()] == ["device-cloud-a"] + assert store.get("local-device-a")["cloud_device_id"] == "device-cloud-a" + + +def test_register_local_device_not_enrollment_managed_registers_under_local_id( + tmp_path, +) -> None: + store = DeviceConfigStore(tmp_path / "devices.sqlite3") + manager = DeviceManager() + enrollment_client = RecordingEnrollmentClient("device-cloud-a") + + register_local_device( + store, + manager, + device_id="local-device-a", + driver_type="wda", + connection_info={"server_url": "http://127.0.0.1:4723"}, + name="Lab iPhone", + config=_config(enrollment_managed=False), + enrollment_client=enrollment_client, # type: ignore[arg-type] + ) + + assert enrollment_client.calls == [] + assert [device.id for device in manager.list_devices()] == ["local-device-a"] + assert store.get("local-device-a")["cloud_device_id"] is None + + +def test_register_local_device_reregister_under_new_cloud_id_replaces_prior_entry( + tmp_path, +) -> None: + store = DeviceConfigStore(tmp_path / "devices.sqlite3") + manager = DeviceManager() + config = _config(enrollment_managed=True) + + register_local_device( + store, + manager, + device_id="local-device-a", + driver_type="wda", + connection_info={"server_url": "http://127.0.0.1:4723"}, + name="Lab iPhone", + config=config, + enrollment_client=RecordingEnrollmentClient( # type: ignore[arg-type] + "device-cloud-a" + ), + ) + assert [device.id for device in manager.list_devices()] == ["device-cloud-a"] + + register_local_device( + store, + manager, + device_id="local-device-a", + driver_type="wda", + connection_info={"server_url": "http://127.0.0.1:5000"}, + name="Lab iPhone (moved)", + config=config, + enrollment_client=RecordingEnrollmentClient( # type: ignore[arg-type] + "device-cloud-b" + ), + ) + + devices = manager.list_devices() + assert [device.id for device in devices] == ["device-cloud-b"] + assert devices[0].name == "Lab iPhone (moved)" + assert devices[0].connection_info == {"server_url": "http://127.0.0.1:5000"} + assert store.get("local-device-a")["cloud_device_id"] == "device-cloud-b" + + +def test_unregister_local_device_removes_device_with_cloud_id(tmp_path) -> None: + store = DeviceConfigStore(tmp_path / "devices.sqlite3") + manager = DeviceManager() + register_local_device( + store, + manager, + device_id="local-device-a", + driver_type="wda", + connection_info={}, + name=None, + config=_config(enrollment_managed=True), + enrollment_client=RecordingEnrollmentClient( # type: ignore[arg-type] + "device-cloud-a" + ), + ) + + unregister_local_device(store, manager, device_id="local-device-a") + + assert manager.list_devices() == [] + assert store.get("local-device-a") is None + + +def test_unregister_local_device_removes_device_without_cloud_id(tmp_path) -> None: + store = DeviceConfigStore(tmp_path / "devices.sqlite3") + manager = DeviceManager() + register_local_device( + store, + manager, + device_id="local-device-a", + driver_type="wda", + connection_info={}, + name=None, + config=_config(enrollment_managed=False), + enrollment_client=None, + ) + + unregister_local_device(store, manager, device_id="local-device-a") + + assert manager.list_devices() == [] + assert store.get("local-device-a") is None + + +def test_unregister_local_device_is_a_no_op_for_unknown_device(tmp_path) -> None: + store = DeviceConfigStore(tmp_path / "devices.sqlite3") + manager = DeviceManager() + + unregister_local_device(store, manager, device_id="unknown-device") + + assert manager.list_devices() == [] diff --git a/apps/device-host-agent/tests/test_e2e.py b/apps/device-host-agent/tests/test_e2e.py index ce92bdc..29d0b83 100644 --- a/apps/device-host-agent/tests/test_e2e.py +++ b/apps/device-host-agent/tests/test_e2e.py @@ -10,12 +10,12 @@ import httpx import pytest from fastapi.testclient import TestClient -from cloud.auth import BearerCredential +from cloud.auth import digest_token from cloud.control_config import CloudControlConfig from cloud.sdk.client import CloudClient from cloud.scheduler import TaskConstraints from cloud_api.app import create_app -from core.models import Scene +from core.models import Scene, utc_now from device.manager import DeviceManager from driver.base import Driver from host_agent.assignment import AssignmentExecutionResult, AssignmentExecutor @@ -79,23 +79,6 @@ class FakeDriver(Driver): return None -def _credential(host_id: str) -> BearerCredential: - return BearerCredential( - principal_id=f"agent-{host_id}", - token=f"token-{host_id}", - scopes=frozenset(), - host_id=host_id, - ) - - -def _public_credential() -> BearerCredential: - return BearerCredential( - principal_id="sdk", - token="public-token", - scopes=frozenset({"tasks:submit", "tasks:read"}), - ) - - def _config(host_id: str) -> HostAgentConfig: return HostAgentConfig( control_plane_url="http://control.test", @@ -120,10 +103,18 @@ async def _control_plane( scheduler_interval_seconds=60, lease_reaper_interval_seconds=60, lease_duration_seconds=lease_duration_seconds, - credentials=tuple(_credential(host_id) for host_id in host_ids), ) ) async with app.router.lifespan_context(app): + for host_id in host_ids: + app.state.cloud_services.repository.enroll_host( + host_id=host_id, + agent_instance_id=f"agent-{host_id}", + credential_digest=digest_token(f"token-{host_id}"), + enrollment_token_digest=None, + display_name=host_id, + enrolled_at=utc_now(), + ) yield app @@ -162,12 +153,22 @@ class _RecordingTransport(httpx.AsyncBaseTransport): async def _sync_fake_device( + app, client: HostAgentClient, host_id: str, device_id: str, *, driver_type: str = "wda", ) -> FakeDriver: + app.state.cloud_services.repository.enroll_device( + device_id=device_id, + host_id=host_id, + local_device_id=device_id, + driver_type=driver_type, + name=device_id, + capability_tags=[], + enrolled_at=utc_now(), + ) driver = FakeDriver() manager = DeviceManager() manager.register_device( @@ -248,7 +249,7 @@ def test_one_host_executes_assignment_through_outbound_protocol(tmp_path) -> Non paths: list[str] = [] async with _control_plane(tmp_path / "one-host.sqlite3", "host-a") as app: async with _host_client(app, "host-a", request_paths=paths) as client: - await _sync_fake_device(client, "host-a", "device-a") + await _sync_fake_device(app, client, "host-a", "device-a") task_id = app.state.cloud_services.scheduler.submit( goal="open settings" ) @@ -275,7 +276,7 @@ def test_nat_style_host_requires_only_outbound_requests(tmp_path) -> None: paths: list[str] = [] async with _control_plane(tmp_path / "outbound-only.sqlite3", "host-a") as app: async with _host_client(app, "host-a", request_paths=paths) as client: - await _sync_fake_device(client, "host-a", "device-a") + await _sync_fake_device(app, client, "host-a", "device-a") assert await client.claim() is None assert paths == [ @@ -297,8 +298,9 @@ def test_multiple_hosts_claim_only_their_matching_devices(tmp_path) -> None: _host_client(app, "host-a") as client_a, _host_client(app, "host-b") as client_b, ): - await _sync_fake_device(client_a, "host-a", "device-a") + await _sync_fake_device(app, client_a, "host-a", "device-a") await _sync_fake_device( + app, client_b, "host-b", "device-b", @@ -331,7 +333,7 @@ def test_control_plane_restart_preserves_dispatched_assignment(tmp_path) -> None assignment = None async with _control_plane(database_path, "host-a") as first_app: async with _host_client(first_app, "host-a") as client: - await _sync_fake_device(client, "host-a", "device-a") + await _sync_fake_device(first_app, client, "host-a", "device-a") task_id = first_app.state.cloud_services.scheduler.submit(goal="resume") first_app.state.cloud_services.scheduler.assign() assignment = await client.claim() @@ -351,7 +353,7 @@ def test_host_agent_restart_reuses_active_lease(tmp_path) -> None: async def scenario() -> None: async with _control_plane(tmp_path / "agent-restart.sqlite3", "host-a") as app: async with _host_client(app, "host-a") as first_client: - await _sync_fake_device(first_client, "host-a", "device-a") + await _sync_fake_device(app, first_client, "host-a", "device-a") task_id = app.state.cloud_services.scheduler.submit(goal="resume host") app.state.cloud_services.scheduler.assign() assignment = await first_client.claim() @@ -376,7 +378,7 @@ def test_lease_loss_rejects_stale_host_result(tmp_path) -> None: lease_duration_seconds=0.2, ) as app: async with _host_client(app, "host-a") as client: - await _sync_fake_device(client, "host-a", "device-a") + await _sync_fake_device(app, client, "host-a", "device-a") task_id = app.state.cloud_services.scheduler.submit(goal="expire") app.state.cloud_services.scheduler.assign() assignment = await client.claim() @@ -405,7 +407,6 @@ def test_public_sdk_reports_fake_device_success_and_runtime_failure(tmp_path) -> scheduler_interval_seconds=60, lease_reaper_interval_seconds=60, lease_duration_seconds=30, - credentials=(_public_credential(), _credential("host-a")), ) ) driver = FakeDriver() @@ -413,10 +414,38 @@ def test_public_sdk_reports_fake_device_success_and_runtime_failure(tmp_path) -> manager.register_device("device-a", lambda: driver, status="idle") with TestClient(app) as http_client: + app.state.cloud_services.user_auth_service.create_user( + username="operator", + display_name="Operator", + role="admin", + password="correct-horse-battery-staple", + must_change_password=False, + ) + login = http_client.post( + "/v1/auth/login", + json={"username": "operator", "password": "correct-horse-battery-staple"}, + ) + assert login.status_code == 200 + app.state.cloud_services.repository.enroll_host( + host_id="host-a", + agent_instance_id="agent-host-a", + credential_digest=digest_token("token-host-a"), + enrollment_token_digest=None, + display_name="host-a", + enrolled_at=utc_now(), + ) + app.state.cloud_services.repository.enroll_device( + device_id="device-a", + host_id="host-a", + local_device_id="device-a", + driver_type="wda", + name="device-a", + capability_tags=[], + enrolled_at=utc_now(), + ) cloud_client = CloudClient( "http://testserver", http_client=http_client, - token="public-token", ) async def scenario() -> None: @@ -428,9 +457,7 @@ def test_public_sdk_reports_fake_device_success_and_runtime_failure(tmp_path) -> heartbeat.connect_devices() await heartbeat.sync_once() - successful_task_id = cloud_client.submit_task(goal="tap screen")[ - "task_id" - ] + successful_task_id = cloud_client.submit_task(goal="tap screen")["task_id"] app.state.cloud_services.scheduler.assign() successful_assignment = await host_client.claim() assert successful_assignment is not None diff --git a/apps/device-host-agent/tests/test_enrollment.py b/apps/device-host-agent/tests/test_enrollment.py index bfa0bdc..211aeff 100644 --- a/apps/device-host-agent/tests/test_enrollment.py +++ b/apps/device-host-agent/tests/test_enrollment.py @@ -31,12 +31,12 @@ class _RejectingEnrollmentClient: raise HostAgentAPIError(401, "unauthorized") -def test_fresh_install_with_no_token_self_enrolls(tmp_path) -> None: +def test_fresh_install_directly_enrolls(tmp_path) -> None: identity_store = HostIdentityStore(tmp_path / "identity.json") client = _RecordingEnrollmentClient() resolved = resolve_host_identity( - _config(enrollment_token=""), + _config(), identity_store=identity_store, client=client, ) @@ -52,7 +52,7 @@ def test_self_service_rejection_propagates_as_api_error(tmp_path) -> None: try: resolve_host_identity( - _config(enrollment_token=""), + _config(), identity_store=identity_store, client=client, ) @@ -63,19 +63,6 @@ def test_self_service_rejection_propagates_as_api_error(tmp_path) -> None: assert identity_store.load().host_id is None -def test_configured_enrollment_token_still_used_when_present(tmp_path) -> None: - identity_store = HostIdentityStore(tmp_path / "identity.json") - client = _RecordingEnrollmentClient() - - resolve_host_identity( - _config(enrollment_token="one-time-token"), - identity_store=identity_store, - client=client, - ) - - assert client.calls[0]["agent_instance_id"] - - def test_existing_cached_identity_skips_enrollment(tmp_path) -> None: identity_store = HostIdentityStore(tmp_path / "identity.json") identity_store.complete(identity_store.load_or_create(), "host-cloud-a") @@ -85,7 +72,7 @@ def test_existing_cached_identity_skips_enrollment(tmp_path) -> None: raise AssertionError("cached identity must skip enrollment") resolved = resolve_host_identity( - _config(enrollment_token=""), + _config(), identity_store=identity_store, client=ExplodingClient(), # type: ignore[arg-type] ) diff --git a/apps/device-host-agent/tests/test_heartbeat.py b/apps/device-host-agent/tests/test_heartbeat.py index 4c7d006..7b42aa3 100644 --- a/apps/device-host-agent/tests/test_heartbeat.py +++ b/apps/device-host-agent/tests/test_heartbeat.py @@ -7,6 +7,7 @@ from cloud.internal_api.models import HeartbeatResponse from device.manager import DeviceManager from host_agent.config import HostAgentConfig from host_agent.heartbeat import HeartbeatSynchronizer, build_device_snapshot +from host_agent.status import AgentStatusTracker class ConnectableDriver: @@ -81,3 +82,44 @@ def test_heartbeat_synchronizer_runs_at_configured_interval_until_stopped() -> N asyncio.run(scenario()) assert calls == [["device-a"], ["device-a"], ["device-a"]] assert manager.status("device-a") == "busy" + + +def test_sync_once_notifies_status_tracker_and_on_sync_with_device_count() -> None: + manager = DeviceManager() + manager.register_device( + "device-a", + lambda: ConnectableDriver(), # type: ignore[arg-type,return-value] + ) + manager.register_device( + "device-b", + lambda: ConnectableDriver(), # type: ignore[arg-type,return-value] + ) + + class FakeClient: + async def heartbeat(self, devices, *, address=None): + return HeartbeatResponse( + host_id="host-a", + accepted_devices=len(devices), + received_at=datetime.now(UTC), + ) + + async def scenario() -> None: + tracker = AgentStatusTracker() + on_sync_calls: list[int] = [] + synchronizer = HeartbeatSynchronizer( + manager, + FakeClient(), # type: ignore[arg-type] + _config(), + status_tracker=tracker, + on_sync=on_sync_calls.append, + ) + + await synchronizer.sync_once() + + assert on_sync_calls == [2] + last_heartbeat = tracker.snapshot()["last_heartbeat"] + assert last_heartbeat is not None + assert last_heartbeat["ok"] is True + assert last_heartbeat["device_count"] == 2 + + asyncio.run(scenario()) diff --git a/apps/device-host-agent/tests/test_history.py b/apps/device-host-agent/tests/test_history.py new file mode 100644 index 0000000..2021bba --- /dev/null +++ b/apps/device-host-agent/tests/test_history.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from datetime import UTC, datetime + +from host_agent.history import ConsoleHistoryStore + + +def test_history_store_records_assignment_and_heartbeat_newest_first(tmp_path) -> None: + store = ConsoleHistoryStore(tmp_path / "history.sqlite3") + + store.record_assignment( + task_id="task-a", + attempt=1, + status="done", + failure_reason=None, + device_id="device-a", + ) + store.record_heartbeat(device_count=2) + + entries = store.list_recent() + + assert len(entries) == 2 + assert entries[0] == { + "kind": "heartbeat", + "occurred_at": entries[0]["occurred_at"], + "summary": "heartbeat: 2 devices", + "detail": {"device_count": 2}, + } + assert entries[1] == { + "kind": "assignment", + "occurred_at": entries[1]["occurred_at"], + "summary": "task-a attempt 1 on device-a: done", + "detail": { + "task_id": "task-a", + "attempt": 1, + "status": "done", + "failure_reason": None, + "device_id": "device-a", + }, + } + + +def test_history_store_prunes_oldest_entries_beyond_limit(tmp_path) -> None: + store = ConsoleHistoryStore(tmp_path / "history.sqlite3", limit=3) + + for index in range(5): + store.record_heartbeat(device_count=index) + + entries = store.list_recent() + + assert len(entries) == 3 + assert [entry["detail"]["device_count"] for entry in entries] == [4, 3, 2] + + +def test_history_store_returns_empty_list_when_no_entries(tmp_path) -> None: + store = ConsoleHistoryStore(tmp_path / "history.sqlite3") + + assert store.list_recent() == [] + + +def test_history_store_uses_injected_now_for_occurred_at(tmp_path) -> None: + fixed_now = datetime(2026, 1, 1, 12, 0, 0, tzinfo=UTC) + store = ConsoleHistoryStore(tmp_path / "history.sqlite3", now=lambda: fixed_now) + + store.record_heartbeat(device_count=1) + + entries = store.list_recent() + + assert entries[0]["occurred_at"] == fixed_now.isoformat() diff --git a/apps/device-host-agent/tests/test_processor.py b/apps/device-host-agent/tests/test_processor.py index 7484f74..c0f8be2 100644 --- a/apps/device-host-agent/tests/test_processor.py +++ b/apps/device-host-agent/tests/test_processor.py @@ -6,6 +6,7 @@ from datetime import UTC, datetime from cloud.internal_api.models import AssignmentModel, TerminalResultResponse from host_agent.assignment import AssignmentExecutionResult from host_agent.processor import AssignmentProcessor +from host_agent.status import AgentStatusTracker def _assignment() -> AssignmentModel: @@ -85,3 +86,69 @@ def test_processor_preserves_runtime_failure_reason() -> None: } asyncio.run(scenario()) + + +def test_status_tracker_sees_started_then_finished_even_on_raise() -> None: + async def scenario() -> None: + tracker = AgentStatusTracker() + snapshots: list[dict[str, object]] = [] + + class RaisingExecutor: + async def run(self, assignment): + snapshots.append(tracker.snapshot()) + raise RuntimeError("executor exploded") + + class RecordingClient: + async def report_result(self, assignment, **kwargs): + return TerminalResultResponse(status="recorded") + + processor = AssignmentProcessor( + RecordingClient(), # type: ignore[arg-type] + RaisingExecutor(), + status_tracker=tracker, + ) + + try: + await processor.process(_assignment()) + except RuntimeError: + pass + + assert snapshots[0]["current_assignment"] is not None + assert snapshots[0]["current_assignment"]["task_id"] == "task-a" + assert tracker.snapshot()["current_assignment"] is None + + asyncio.run(scenario()) + + +def test_on_result_receives_assignment_and_result_and_swallows_exceptions() -> None: + async def scenario() -> None: + received: list[tuple[object, object]] = [] + + class SuccessfulExecutor: + async def run(self, assignment): + return AssignmentExecutionResult( + status="done", + failure_reason=None, + metadata={}, + ) + + class RecordingClient: + async def report_result(self, assignment, **kwargs): + return TerminalResultResponse(status="recorded") + + def on_result(assignment, result) -> None: + received.append((assignment, result)) + raise RuntimeError("history recording exploded") + + assignment = _assignment() + processor = AssignmentProcessor( + RecordingClient(), # type: ignore[arg-type] + SuccessfulExecutor(), + on_result=on_result, + ) + + result = await processor.process(assignment) + + assert received == [(assignment, result)] + + asyncio.run(scenario()) diff --git a/apps/device-host-agent/tests/test_status.py b/apps/device-host-agent/tests/test_status.py new file mode 100644 index 0000000..ee2fcea --- /dev/null +++ b/apps/device-host-agent/tests/test_status.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from datetime import UTC, datetime + +from cloud.internal_api.models import AssignmentModel +from host_agent.status import AgentStatusTracker + + +def _assignment() -> AssignmentModel: + return AssignmentModel( + task_id="task-a", + attempt=1, + lease_id="lease-a", + lease_expires_at=datetime(2026, 7, 12, tzinfo=UTC), + host_id="host-a", + device_id="device-a", + goal="open settings", + workflow_definition_id="workflow-a", + ) + + +def test_mark_assignment_started_reflected_in_snapshot() -> None: + ticks = iter([datetime(2026, 7, 13, 9, 0, 0, tzinfo=UTC)]) + tracker = AgentStatusTracker(now=lambda: next(ticks)) + + tracker.mark_assignment_started(_assignment()) + + snapshot = tracker.snapshot() + assert snapshot["current_assignment"] == { + "task_id": "task-a", + "device_id": "device-a", + "goal": "open settings", + "workflow_definition_id": "workflow-a", + "started_at": "2026-07-13T09:00:00+00:00", + } + + +def test_mark_assignment_finished_clears_current_assignment() -> None: + tracker = AgentStatusTracker(now=lambda: datetime(2026, 7, 13, 9, 0, 0, tzinfo=UTC)) + + tracker.mark_assignment_started(_assignment()) + tracker.mark_assignment_finished() + + assert tracker.snapshot()["current_assignment"] is None + + +def test_mark_heartbeat_reflected_in_snapshot() -> None: + tracker = AgentStatusTracker(now=lambda: datetime(2026, 7, 13, 9, 5, 0, tzinfo=UTC)) + + tracker.mark_heartbeat(ok=True, device_count=3) + + snapshot = tracker.snapshot() + assert snapshot["last_heartbeat"] == { + "ok": True, + "device_count": 3, + "at": "2026-07-13T09:05:00+00:00", + } + + +def test_snapshot_defaults_to_no_assignment_or_heartbeat() -> None: + tracker = AgentStatusTracker(now=lambda: datetime(2026, 7, 13, 9, 0, 0, tzinfo=UTC)) + + snapshot = tracker.snapshot() + + assert snapshot["current_assignment"] is None + assert snapshot["last_heartbeat"] is None diff --git a/apps/device-host-agent/tests/test_web_app.py b/apps/device-host-agent/tests/test_web_app.py new file mode 100644 index 0000000..ae0af3d --- /dev/null +++ b/apps/device-host-agent/tests/test_web_app.py @@ -0,0 +1,262 @@ +from __future__ import annotations + +import re + +from fastapi.testclient import TestClient + +from device.manager import DeviceManager +from host_agent.config import HostAgentConfig +from host_agent.history import ConsoleHistoryStore +from host_agent.identity import HostIdentityStore +from host_agent.local_account import LocalAccountStore +from host_agent.status import AgentStatusTracker +from host_agent.web.app import SESSION_COOKIE_NAME, create_console_app +from host_agent.web.auth import SessionManager +from storage.device_config import DeviceConfigStore + +CSRF_PATTERN = re.compile(r'name="csrf_token" value="([^"]+)"') + + +def _build_client(tmp_path, *, create_account: bool = True) -> tuple[TestClient, dict]: + config = HostAgentConfig( + control_plane_url="https://control.example", + host_id="host-a", + token="secret", + enrollment_managed=False, + console_session_ttl_seconds=3600.0, + ) + manager = DeviceManager() + config_store = DeviceConfigStore(tmp_path / "devices.sqlite3") + local_account_store = LocalAccountStore(tmp_path / "host_local_account.json") + if create_account: + local_account_store.create("operator", "correct horse battery staple") + identity_store = HostIdentityStore(tmp_path / "host_identity.json") + history_store = ConsoleHistoryStore(tmp_path / "history.sqlite3") + status_tracker = AgentStatusTracker() + session_manager = SessionManager(ttl_seconds=3600.0) + + app = create_console_app( + config=config, + manager=manager, + config_store=config_store, + local_account_store=local_account_store, + identity_store=identity_store, + history_store=history_store, + status_tracker=status_tracker, + session_manager=session_manager, + enrollment_client=None, + ) + client = TestClient(app) + context = { + "manager": manager, + "config_store": config_store, + "local_account_store": local_account_store, + "history_store": history_store, + "session_manager": session_manager, + } + return client, context + + +def _login( + client: TestClient, + *, + username: str = "operator", + password: str = "correct horse battery staple", +) -> str: + response = client.post("/login", data={"username": username, "password": password}) + assert response.status_code == 200 + match = CSRF_PATTERN.search(response.text) + assert match is not None + return match.group(1) + + +def test_unauthenticated_get_root_redirects_to_login(tmp_path) -> None: + client, _ = _build_client(tmp_path) + + response = client.get("/", follow_redirects=False) + + assert response.status_code == 303 + assert response.headers["location"] == "/login" + + +def test_login_with_no_account_shows_setup_message_and_rejects_post(tmp_path) -> None: + client, context = _build_client(tmp_path, create_account=False) + + get_response = client.get("/login") + assert "device-host-agent setup" in get_response.text + + post_response = client.post( + "/login", data={"username": "operator", "password": "anything"} + ) + + assert "device-host-agent setup" in post_response.text + assert SESSION_COOKIE_NAME not in client.cookies + + +def test_login_with_wrong_password_fails_and_sets_no_cookie(tmp_path) -> None: + client, _ = _build_client(tmp_path) + + response = client.post("/login", data={"username": "operator", "password": "wrong"}) + + assert response.status_code == 200 + assert "Invalid username or password" in response.text + assert SESSION_COOKIE_NAME not in client.cookies + + +def test_login_with_correct_password_sets_cookie_and_dashboard_succeeds( + tmp_path, +) -> None: + client, _ = _build_client(tmp_path) + + response = client.post( + "/login", + data={"username": "operator", "password": "correct horse battery staple"}, + ) + + assert response.status_code == 200 + assert SESSION_COOKIE_NAME in client.cookies + assert "Status" in response.text + assert "control.example" in response.text + + +def test_mutating_post_without_csrf_token_is_rejected_and_makes_no_change( + tmp_path, +) -> None: + client, context = _build_client(tmp_path) + _login(client) + + response = client.post( + "/devices/save", + data={ + "device_id": "device-a", + "driver_type": "wda", + "name": "Lab iPhone", + "connection_info": "{}", + }, + ) + + assert response.status_code == 403 + assert context["config_store"].get("device-a") is None + assert context["manager"].list_devices() == [] + + +def test_add_device_appears_in_devices_page_and_manager(tmp_path) -> None: + client, context = _build_client(tmp_path) + csrf_token = _login(client) + + response = client.post( + "/devices/save", + data={ + "device_id": "device-a", + "driver_type": "wda", + "name": "Lab iPhone", + "connection_info": "{}", + "csrf_token": csrf_token, + }, + ) + + assert response.status_code == 200 + assert "device-a" in response.text + assert context["config_store"].get("device-a") is not None + assert [device.id for device in context["manager"].list_devices()] == ["device-a"] + + +def test_remove_device_unregisters_from_manager(tmp_path) -> None: + client, context = _build_client(tmp_path) + csrf_token = _login(client) + client.post( + "/devices/save", + data={ + "device_id": "device-a", + "driver_type": "wda", + "name": "Lab iPhone", + "connection_info": "{}", + "csrf_token": csrf_token, + }, + ) + assert context["config_store"].get("device-a") is not None + + response = client.post( + "/devices/remove", + data={"device_id": "device-a", "csrf_token": csrf_token}, + ) + + assert response.status_code == 200 + assert context["config_store"].get("device-a") is None + assert context["manager"].list_devices() == [] + + +def test_change_password_wrong_current_fails_old_password_still_works(tmp_path) -> None: + client, context = _build_client(tmp_path) + csrf_token = _login(client) + + response = client.post( + "/account", + data={ + "current_password": "wrong", + "new_password": "new password", + "confirm_password": "new password", + "csrf_token": csrf_token, + }, + ) + + assert response.status_code == 400 + assert "incorrect" in response.text + account_store: LocalAccountStore = context["local_account_store"] + account = account_store.load() + assert account is not None + assert account_store.verify(account, "correct horse battery staple") is True + + +def test_change_password_correct_succeeds_old_password_no_longer_works( + tmp_path, +) -> None: + client, context = _build_client(tmp_path) + csrf_token = _login(client) + + response = client.post( + "/account", + data={ + "current_password": "correct horse battery staple", + "new_password": "new password", + "confirm_password": "new password", + "csrf_token": csrf_token, + }, + ) + + assert response.status_code == 200 + assert "Password updated" in response.text + account_store: LocalAccountStore = context["local_account_store"] + account = account_store.load() + assert account is not None + assert account_store.verify(account, "new password") is True + assert account_store.verify(account, "correct horse battery staple") is False + + +def test_history_reflects_entries_written_beforehand(tmp_path) -> None: + client, context = _build_client(tmp_path) + context["history_store"].record_heartbeat(device_count=3) + _login(client) + + response = client.get("/history") + + assert response.status_code == 200 + assert "heartbeat: 3 devices" in response.text + + +def test_logout_invalidates_session_so_subsequent_request_redirects_to_login( + tmp_path, +) -> None: + client, _ = _build_client(tmp_path) + csrf_token = _login(client) + + logout_response = client.post( + "/logout", data={"csrf_token": csrf_token}, follow_redirects=False + ) + assert logout_response.status_code == 303 + assert logout_response.headers["location"] == "/login" + + response = client.get("/", follow_redirects=False) + + assert response.status_code == 303 + assert response.headers["location"] == "/login" diff --git a/apps/device-host-agent/tests/test_web_auth.py b/apps/device-host-agent/tests/test_web_auth.py new file mode 100644 index 0000000..5a71137 --- /dev/null +++ b/apps/device-host-agent/tests/test_web_auth.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +from host_agent.local_account import LocalAccountStore +from host_agent.web.auth import SessionManager, attempt_login, change_password + + +class FakeClock: + def __init__(self, start: datetime) -> None: + self.current = start + + def __call__(self) -> datetime: + return self.current + + def advance(self, seconds: float) -> None: + self.current += timedelta(seconds=seconds) + + +def test_create_session_then_validate_returns_username() -> None: + clock = FakeClock(datetime(2026, 1, 1, tzinfo=UTC)) + manager = SessionManager(ttl_seconds=60, now=clock) + + session_token, csrf_token = manager.create_session("operator") + state = manager.validate(session_token) + + assert state is not None + assert state.username == "operator" + assert state.csrf_token == csrf_token + + +def test_validate_unknown_token_returns_none() -> None: + manager = SessionManager(ttl_seconds=60) + + assert manager.validate("does-not-exist") is None + + +def test_validate_after_ttl_elapsed_returns_none() -> None: + clock = FakeClock(datetime(2026, 1, 1, tzinfo=UTC)) + manager = SessionManager(ttl_seconds=60, now=clock) + + session_token, _ = manager.create_session("operator") + clock.advance(61) + + assert manager.validate(session_token) is None + + +def test_validate_before_expiry_slides_expiry_forward() -> None: + clock = FakeClock(datetime(2026, 1, 1, tzinfo=UTC)) + manager = SessionManager(ttl_seconds=60, now=clock) + + session_token, _ = manager.create_session("operator") + clock.advance(30) + first = manager.validate(session_token) + assert first is not None + + clock.advance(30) + second = manager.validate(session_token) + + assert second is not None + assert second.expires_at > first.expires_at + + +def test_validate_csrf_true_for_right_token() -> None: + manager = SessionManager(ttl_seconds=60) + session_token, csrf_token = manager.create_session("operator") + + assert manager.validate_csrf(session_token, csrf_token) is True + + +def test_validate_csrf_false_for_wrong_token() -> None: + manager = SessionManager(ttl_seconds=60) + session_token, _ = manager.create_session("operator") + + assert manager.validate_csrf(session_token, "wrong-token") is False + + +def test_validate_csrf_false_for_invalid_session() -> None: + manager = SessionManager(ttl_seconds=60) + + assert manager.validate_csrf("does-not-exist", "anything") is False + + +def test_invalidate_makes_subsequent_validate_return_none() -> None: + manager = SessionManager(ttl_seconds=60) + session_token, _ = manager.create_session("operator") + + manager.invalidate(session_token) + + assert manager.validate(session_token) is None + + +def test_attempt_login_true_for_correct_credentials(tmp_path) -> None: + store = LocalAccountStore(tmp_path / "host_local_account.json") + store.create("operator", "correct horse battery staple") + + assert ( + attempt_login( + store, username="operator", password="correct horse battery staple" + ) + is True + ) + + +def test_attempt_login_false_for_wrong_password(tmp_path) -> None: + store = LocalAccountStore(tmp_path / "host_local_account.json") + store.create("operator", "correct horse battery staple") + + assert attempt_login(store, username="operator", password="wrong") is False + + +def test_attempt_login_false_when_no_account_exists(tmp_path) -> None: + store = LocalAccountStore(tmp_path / "host_local_account.json") + + assert attempt_login(store, username="operator", password="anything") is False + + +def test_attempt_login_false_for_wrong_username(tmp_path) -> None: + store = LocalAccountStore(tmp_path / "host_local_account.json") + store.create("operator", "correct horse battery staple") + + assert ( + attempt_login( + store, username="someone-else", password="correct horse battery staple" + ) + is False + ) + + +def test_change_password_succeeds_and_rotates_credential(tmp_path) -> None: + store = LocalAccountStore(tmp_path / "host_local_account.json") + store.create("operator", "old password") + + assert ( + change_password( + store, current_password="old password", new_password="new password" + ) + is True + ) + assert attempt_login(store, username="operator", password="new password") is True + assert attempt_login(store, username="operator", password="old password") is False + + +def test_change_password_fails_with_wrong_current_password(tmp_path) -> None: + store = LocalAccountStore(tmp_path / "host_local_account.json") + store.create("operator", "old password") + + assert ( + change_password(store, current_password="wrong", new_password="new password") + is False + ) + assert attempt_login(store, username="operator", password="old password") is True + assert attempt_login(store, username="operator", password="new password") is False + + +def test_change_password_fails_when_no_account_exists(tmp_path) -> None: + store = LocalAccountStore(tmp_path / "host_local_account.json") + + assert ( + change_password(store, current_password="anything", new_password="new password") + is False + ) diff --git a/cloud-console/README.md b/cloud-console/README.md index 1377e25..2831a11 100644 --- a/cloud-console/README.md +++ b/cloud-console/README.md @@ -6,10 +6,6 @@ expiring, revocable `HttpOnly` session cookie, while the frontend sends the separate CSRF cookie value on writes. The browser never stores the session secret in JavaScript. -The login screen also offers **Use API token** for existing break-glass or -automation credentials. That token is held only in `sessionStorage`; it remains -compatible with the existing scoped `CLOUD_PUBLIC_CREDENTIALS_JSON` model. - ## Prerequisites - Node.js 20+ @@ -22,7 +18,7 @@ Accounts have fixed roles: - `viewer`: task, device/host, and plugin read views - `operator`: viewer access plus task submission APIs -- `admin`: all API scopes and the Console Users view +- `admin`: all API scopes ## Local development @@ -48,10 +44,7 @@ The Console uses `credentials: include`. `401` returns to the login screen; ## Production `npm run build` type-checks and creates `dist/`. The repository Dockerfile -already builds this bundle into `/app/console-static`; `compose.yaml` and -`compose.deploy.yaml` mount it at the same-origin `/console/` route. No CORS -configuration is required in that deployment shape. - -Administrators can create users, assign roles, enable/disable accounts, reset -temporary passwords, and revoke sessions. All password inputs are cleared from -the UI after a create/reset request succeeds or fails. +already builds this bundle into `/app/console-static` and configures the Cloud +API to serve it at the same-origin `/console/` route. No CORS configuration is +required in that deployment shape. Use `device-cloud-admin` for account +provisioning and recovery. diff --git a/cloud-console/src/App.vue b/cloud-console/src/App.vue index c8d2849..da4fe45 100644 --- a/cloud-console/src/App.vue +++ b/cloud-console/src/App.vue @@ -7,13 +7,10 @@ import { LogOut, MonitorSmartphone, Puzzle, - Users, } from "@lucide/vue"; import { AUTH_INVALID_EVENT, - clearStoredToken, getCurrentUser, - hasTokenMode, logout, } from "./api"; import type { CloudUser } from "./types"; @@ -22,26 +19,23 @@ import PasswordChangeScreen from "./views/PasswordChangeScreen.vue"; import TasksView from "./views/TasksView.vue"; import DevicesView from "./views/DevicesView.vue"; import PluginsView from "./views/PluginsView.vue"; -import UsersView from "./views/UsersView.vue"; -type ViewId = "tasks" | "devices" | "plugins" | "users"; +type ViewId = "tasks" | "devices" | "plugins"; const activeView = ref("tasks"); const currentUser = ref(null); -const tokenMode = ref(false); const loading = ref(true); const authMessage = ref(""); -const isAdmin = computed( - () => currentUser.value?.scopes.includes("*") || currentUser.value?.scopes.includes("users:admin"), -); const canAdminPlugins = computed( () => - tokenMode.value || currentUser.value?.scopes.includes("*") || currentUser.value?.scopes.includes("plugins:admin"), ); -const isAuthenticated = computed(() => currentUser.value !== null || tokenMode.value); +const isAuthenticated = computed(() => currentUser.value !== null); +const currentUserLabel = computed(() => + currentUser.value ? `${currentUser.value.display_name} (${currentUser.value.role})` : "", +); const mustChangePassword = computed(() => currentUser.value?.must_change_password ?? false); const navItems = computed<{ id: ViewId; label: string; icon: Component }[]>(() => { const items: { id: ViewId; label: string; icon: Component }[] = [ @@ -49,20 +43,16 @@ const navItems = computed<{ id: ViewId; label: string; icon: Component }[]>(() = { id: "devices", label: "Devices", icon: MonitorSmartphone }, { id: "plugins", label: "Plugins", icon: Puzzle }, ]; - if (isAdmin.value) items.push({ id: "users", label: "Users", icon: Users }); return items; }); async function initializeAuthentication() { loading.value = true; currentUser.value = null; - tokenMode.value = hasTokenMode(); - if (!tokenMode.value) { - try { - currentUser.value = await getCurrentUser(); - } catch { - // A missing session is the normal initial state. - } + try { + currentUser.value = await getCurrentUser(); + } catch { + // A missing session is the normal initial state. } loading.value = false; } @@ -74,8 +64,7 @@ async function onAuthenticated() { function onAuthInvalid() { currentUser.value = null; - tokenMode.value = false; - authMessage.value = "your session expired or credentials were rejected. sign in again."; + authMessage.value = "your session expired. sign in again."; } async function signOut() { @@ -84,15 +73,12 @@ async function signOut() { } catch { // Local state must still be cleared when the already-expired session rejects logout. } - clearStoredToken(); currentUser.value = null; - tokenMode.value = false; authMessage.value = ""; } function onPasswordChanged() { currentUser.value = null; - tokenMode.value = false; authMessage.value = "password changed. sign in with the new password."; } @@ -111,8 +97,6 @@ const activeComponent = computed(() => { return DevicesView; case "plugins": return PluginsView; - case "users": - return UsersView; default: return TasksView; } @@ -139,12 +123,11 @@ const activeComponent = computed(() => { {{ item.label }}
-
{{ currentUser ? `${currentUser.display_name} (${currentUser.role})` : "API token" }}
+
{{ currentUserLabel }}
-
diff --git a/cloud-console/src/api.test.ts b/cloud-console/src/api.test.ts index 80ec152..68d969b 100644 --- a/cloud-console/src/api.test.ts +++ b/cloud-console/src/api.test.ts @@ -3,12 +3,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AUTH_INVALID_EVENT, - clearStoredToken, - getStoredToken, listDevices, login, registerPlugin, - storeToken, } from "./api"; function response(payload: unknown, status = 200): Response { @@ -20,14 +17,12 @@ function response(payload: unknown, status = 200): Response { describe("Cloud Console API authentication", () => { beforeEach(() => { - clearStoredToken(); document.cookie = "amcp_csrf=; Max-Age=0; path=/"; vi.stubGlobal("fetch", vi.fn()); }); afterEach(() => { vi.unstubAllGlobals(); - clearStoredToken(); }); it("uses credentialed account login without a bearer header", async () => { @@ -79,34 +74,17 @@ describe("Cloud Console API authentication", () => { ); }); - it("keeps the explicit compatibility bearer-token path", async () => { - storeToken("compatibility-token"); - vi.mocked(fetch).mockResolvedValueOnce(response([])); - - await listDevices(); - - expect(fetch).toHaveBeenCalledWith( - expect.stringMatching(/\/v1\/devices$/), - expect.objectContaining({ - headers: expect.objectContaining({ Authorization: "Bearer compatibility-token" }), - }), - ); - }); - - it("clears authentication only on 401, not on 403", async () => { - storeToken("compatibility-token"); + it("invalidates the session only on 401", async () => { const invalidated = vi.fn(); window.addEventListener(AUTH_INVALID_EVENT, invalidated); vi.mocked(fetch).mockResolvedValueOnce(response({ detail: "unauthorized" }, 401)); await expect(listDevices()).rejects.toMatchObject({ status: 401 }); - expect(getStoredToken()).toBeNull(); expect(invalidated).toHaveBeenCalledTimes(1); - storeToken("compatibility-token"); vi.mocked(fetch).mockResolvedValueOnce(response({ detail: "forbidden" }, 403)); await expect(listDevices()).rejects.toMatchObject({ status: 403 }); - expect(getStoredToken()).toBe("compatibility-token"); + expect(invalidated).toHaveBeenCalledTimes(1); window.removeEventListener(AUTH_INVALID_EVENT, invalidated); }); }); diff --git a/cloud-console/src/api.ts b/cloud-console/src/api.ts index 094db33..52b1a70 100644 --- a/cloud-console/src/api.ts +++ b/cloud-console/src/api.ts @@ -7,9 +7,6 @@ import type { TaskAttempt, TaskListResponse, TaskStatus, - UserCreatePayload, - UserListResponse, - UserUpdatePayload, } from "./types"; const configuredBaseUrl = import.meta.env.VITE_CLOUD_API_BASE_URL as @@ -21,7 +18,6 @@ export const API_BASE_URL = (configuredBaseUrl || window.location.origin).replac "", ); -const TOKEN_STORAGE_KEY = "cloudConsole.bearerToken"; const CSRF_COOKIE_NAME = "amcp_csrf"; export const AUTH_INVALID_EVENT = "cloud-console:auth-invalid"; @@ -35,47 +31,19 @@ export class CloudApiError extends Error { } } -export function getStoredToken(): string | null { - try { - return sessionStorage.getItem(TOKEN_STORAGE_KEY); - } catch { - return null; - } -} - -export function storeToken(token: string): void { - sessionStorage.setItem(TOKEN_STORAGE_KEY, token); -} - -export function clearStoredToken(): void { - sessionStorage.removeItem(TOKEN_STORAGE_KEY); -} - -export function hasTokenMode(): boolean { - return getStoredToken() !== null; -} - interface RequestInitLike { method?: string; body?: string | null; headers?: Record; - allowAnonymous?: boolean; - sessionOnly?: boolean; } async function request(path: string, init: RequestInitLike = {}): Promise { - const token = init.sessionOnly ? null : getStoredToken(); - if (!init.allowAnonymous && !token && !init.sessionOnly) { - // Session mode is permitted, so a missing token is not itself an error. - } const method = init.method || "GET"; const headers: Record = { Accept: "application/json", ...init.headers, }; - if (token) { - headers.Authorization = `Bearer ${token}`; - } else if (["POST", "PUT", "PATCH", "DELETE"].includes(method)) { + if (["POST", "PUT", "PATCH", "DELETE"].includes(method)) { const csrfToken = getCookie(CSRF_COOKIE_NAME); if (csrfToken) headers["X-CSRF-Token"] = csrfToken; } @@ -89,7 +57,6 @@ async function request(path: string, init: RequestInitLike = {}): Promise credentials: "include", }); if (response.status === 401) { - if (token) clearStoredToken(); window.dispatchEvent(new CustomEvent(AUTH_INVALID_EVENT)); throw new CloudApiError(401, await responseDetail(response, "authentication expired")); } @@ -127,17 +94,15 @@ export function login(username: string, password: string): Promise { return request("/v1/auth/login", { method: "POST", body: JSON.stringify({ username, password }), - allowAnonymous: true, - sessionOnly: true, }); } export function getCurrentUser(): Promise { - return request("/v1/auth/me", { sessionOnly: true }); + return request("/v1/auth/me"); } export function logout(): Promise { - return request("/v1/auth/logout", { method: "POST", sessionOnly: true }); + return request("/v1/auth/logout", { method: "POST" }); } export function changePassword( @@ -150,7 +115,6 @@ export function changePassword( current_password: currentPassword, new_password: newPassword, }), - sessionOnly: true, }); } @@ -189,41 +153,3 @@ export function registerPlugin(payload: PluginRegistrationPayload): Promise { - const params = new URLSearchParams({ - limit: String(options?.limit ?? 50), - offset: String(options?.offset ?? 0), - }); - return request(`/v1/users?${params.toString()}`); -} - -export function createUser(payload: UserCreatePayload): Promise { - return request("/v1/users", { - method: "POST", - body: JSON.stringify(payload), - }); -} - -export function updateUser(userId: string, payload: UserUpdatePayload): Promise { - return request(`/v1/users/${encodeURIComponent(userId)}`, { - method: "PATCH", - body: JSON.stringify(payload), - }); -} - -export function resetUserPassword(userId: string, password: string): Promise { - return request(`/v1/users/${encodeURIComponent(userId)}/password`, { - method: "POST", - body: JSON.stringify({ password }), - }); -} - -export function revokeUserSessions(userId: string): Promise { - return request(`/v1/users/${encodeURIComponent(userId)}/sessions`, { - method: "DELETE", - }); -} diff --git a/cloud-console/src/types.ts b/cloud-console/src/types.ts index 3bdcb7d..b25ea4d 100644 --- a/cloud-console/src/types.ts +++ b/cloud-console/src/types.ts @@ -83,22 +83,3 @@ export interface CloudUser { updated_at: string; last_login_at: string | null; } - -export interface UserListResponse { - items: CloudUser[]; - limit: number; - offset: number; -} - -export interface UserCreatePayload { - username: string; - display_name: string; - role: UserRole; - password: string; -} - -export interface UserUpdatePayload { - display_name?: string; - role?: UserRole; - enabled?: boolean; -} diff --git a/cloud-console/src/views/LoginScreen.vue b/cloud-console/src/views/LoginScreen.vue index a8c7347..d95576a 100644 --- a/cloud-console/src/views/LoginScreen.vue +++ b/cloud-console/src/views/LoginScreen.vue @@ -1,14 +1,12 @@