feat(cloud): remove static credentials and add host console
Tests / Test No test results found

This commit is contained in:
2026-07-13 19:45:53 +08:00
parent efeb3eb926
commit c162c2501b
61 changed files with 3118 additions and 1221 deletions
-43
View File
@@ -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=
+4
View File
@@ -12,3 +12,7 @@
__pycache__/
tasks/
*.egg-info/
*.sqlite
*.sqlite3
+2 -1
View File
@@ -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"]
+1 -16
View File
@@ -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,
)
)
+15 -30
View File
@@ -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:
+115 -22
View File
@@ -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
+1 -1
View File
@@ -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,
+48 -12
View File
@@ -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"}
@@ -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)
+16 -2
View File
@@ -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()
@@ -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
+36 -14
View File
@@ -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
@@ -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
),
}
@@ -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"""
<nav>
<a href="/">Status</a>
<a href="/devices">Devices</a>
<a href="/account">Account</a>
<a href="/history">History</a>
<form class="inline" method="post" action="/logout">
<input type="hidden" name="{CSRF_FORM_FIELD}" value="{escape(session.csrf_token)}">
<button type="submit">Logout</button>
</form>
</nav>
"""
return f"""<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>{escape(title)}</title>
<style>{_CSS}</style>
</head>
<body>
<header>
<strong>Host Agent Console</strong>
{nav}
</header>
<main>
{body_html}
</main>
</body>
</html>"""
def _login_page(
*, account: LocalAccountState | None, error: str | None = None
) -> HTMLResponse:
if account is None:
body = """
<h1>Login</h1>
<p>No local account exists yet. Run <code>device-host-agent setup</code>
on this machine to create one before logging in to the console.</p>
"""
return HTMLResponse(_chrome("Login", body, session=None))
error_html = f'<p class="error">{escape(error)}</p>' if error else ""
body = f"""
<h1>Login</h1>
{error_html}
<form method="post" action="/login">
<label>Username <input type="text" name="username" required></label><br>
<label>Password <input type="password" name="password" required></label><br>
<button type="submit">Log in</button>
</form>
"""
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"<tr><td>{escape(device.id)}</td><td>{escape(device.name or '')}</td>"
f"<td>{escape(device.driver_type)}</td><td>{escape(device.status)}</td></tr>"
for device in devices
)
return f"""
<h1>Status</h1>
<section>
<h2>Enrollment</h2>
<p>Host ID: {escape(identity.host_id if identity else None) or "not enrolled"}</p>
<p>Agent instance ID: {escape(identity.agent_instance_id if identity else None) or "unknown"}</p>
<p>Control plane: {escape(config.control_plane_url)}</p>
</section>
<section>
<h2>Heartbeat</h2>
<p id="last-heartbeat">{escape(heartbeat_text)}</p>
</section>
<section>
<h2>Current assignment</h2>
<p id="current-assignment">{escape(assignment_text)}</p>
</section>
<section>
<h2>Devices</h2>
<table>
<thead><tr><th>ID</th><th>Name</th><th>Driver</th><th>Status</th></tr></thead>
<tbody id="device-status-body">{device_rows}</tbody>
</table>
</section>
<script>
(function () {{
function render(data) {{
var hb = data.status.last_heartbeat;
document.getElementById("last-heartbeat").textContent = hb
? (hb.ok ? "ok" : "failed") + " at " + hb.at + " (" + hb.device_count + " devices)"
: "never";
var current = data.status.current_assignment;
document.getElementById("current-assignment").textContent = current
? current.task_id + " on " + current.device_id + " (started " + current.started_at + ")"
: "none";
var body = document.getElementById("device-status-body");
body.innerHTML = "";
data.devices.forEach(function (device) {{
var row = document.createElement("tr");
["id", "name", "driver_type", "status"].forEach(function (key) {{
var cell = document.createElement("td");
cell.textContent = device[key] || "";
row.appendChild(cell);
}});
body.appendChild(row);
}});
}}
function poll() {{
fetch("/api/status", {{ credentials: "same-origin" }})
.then(function (response) {{ return response.ok ? response.json() : null; }})
.then(function (data) {{ if (data) render(data); }})
.catch(function () {{}});
}}
setInterval(poll, 5000);
}})();
</script>
"""
def _devices_body(
*,
devices: list[dict[str, Any]],
csrf_token: str,
edit_record: dict[str, Any] | None,
error: str | None,
) -> str:
error_html = f'<p class="error">{escape(error)}</p>' if error else ""
rows = "".join(
f"""
<tr>
<td>{escape(device["device_id"])}</td>
<td>{escape(device["name"] or "")}</td>
<td>{escape(device["driver_type"])}</td>
<td>{escape(device["cloud_device_id"] or "")}</td>
<td>
<a href="/devices?edit={escape(device["device_id"])}">Edit</a>
<form class="inline" method="post" action="/devices/remove">
<input type="hidden" name="{CSRF_FORM_FIELD}" value="{escape(csrf_token)}">
<input type="hidden" name="device_id" value="{escape(device["device_id"])}">
<button type="submit">Remove</button>
</form>
</td>
</tr>
"""
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"""
<h1>Devices</h1>
{error_html}
<table>
<thead><tr><th>ID</th><th>Name</th><th>Driver</th><th>Cloud ID</th><th></th></tr></thead>
<tbody>{rows}</tbody>
</table>
<h2>{"Edit device" if edit_record else "Add device"}</h2>
<form method="post" action="/devices/save">
<input type="hidden" name="{CSRF_FORM_FIELD}" value="{escape(csrf_token)}">
<label>Device ID <input type="text" name="device_id" value="{form_device_id}" required></label><br>
<label>Name <input type="text" name="name" value="{form_name}"></label><br>
<label>Driver type <input type="text" name="driver_type" value="{form_driver_type}" required></label><br>
<label>Connection info (JSON)<br>
<textarea name="connection_info" rows="3" cols="50">{form_connection_info}</textarea>
</label><br>
<button type="submit">Save</button>
</form>
"""
def _account_body(*, csrf_token: str, message: str | None, error: str | None) -> str:
message_html = f'<p class="notice">{escape(message)}</p>' if message else ""
error_html = f'<p class="error">{escape(error)}</p>' if error else ""
return f"""
<h1>Account</h1>
{message_html}
{error_html}
<form method="post" action="/account">
<input type="hidden" name="{CSRF_FORM_FIELD}" value="{escape(csrf_token)}">
<label>Current password <input type="password" name="current_password" required></label><br>
<label>New password <input type="password" name="new_password" required></label><br>
<label>Confirm new password <input type="password" name="confirm_password" required></label><br>
<button type="submit">Change password</button>
</form>
"""
def _history_body(entries: list[dict[str, Any]]) -> str:
rows = "".join(
f"<tr><td>{escape(entry['occurred_at'])}</td><td>{escape(entry['kind'])}</td>"
f"<td>{escape(entry['summary'])}</td></tr>"
for entry in entries
)
return f"""
<h1>History</h1>
<table>
<thead><tr><th>Time</th><th>Kind</th><th>Summary</th></tr></thead>
<tbody>{rows}</tbody>
</table>
"""
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
@@ -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
+2
View File
@@ -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]
+104 -1
View File
@@ -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())
+2 -37
View File
@@ -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))
+109 -30
View File
@@ -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)
@@ -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() == []
+58 -31
View File
@@ -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
@@ -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]
)
@@ -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())
@@ -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()
@@ -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())
@@ -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
@@ -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"
@@ -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
)
+5 -12
View File
@@ -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.
+11 -28
View File
@@ -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<ViewId>("tasks");
const currentUser = ref<CloudUser | null>(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(() => {
<component :is="item.icon" :size="14" /> {{ item.label }}
</button>
<div class="spacer" />
<div class="dim">{{ currentUser ? `${currentUser.display_name} (${currentUser.role})` : "API token" }}</div>
<div class="dim">{{ currentUserLabel }}</div>
<button @click="signOut"><LogOut :size="14" /> Sign out</button>
</nav>
<main class="app-main">
<PluginsView v-if="activeView === 'plugins'" :can-admin="canAdminPlugins" />
<UsersView v-else-if="activeView === 'users' && isAdmin" />
<component v-else :is="activeComponent" />
</main>
</div>
+2 -24
View File
@@ -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);
});
});
+3 -77
View File
@@ -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<string, string>;
allowAnonymous?: boolean;
sessionOnly?: boolean;
}
async function request<T>(path: string, init: RequestInitLike = {}): Promise<T> {
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<string, string> = {
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<T>(path: string, init: RequestInitLike = {}): Promise<T>
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<CloudUser> {
return request<CloudUser>("/v1/auth/login", {
method: "POST",
body: JSON.stringify({ username, password }),
allowAnonymous: true,
sessionOnly: true,
});
}
export function getCurrentUser(): Promise<CloudUser> {
return request<CloudUser>("/v1/auth/me", { sessionOnly: true });
return request<CloudUser>("/v1/auth/me");
}
export function logout(): Promise<void> {
return request<void>("/v1/auth/logout", { method: "POST", sessionOnly: true });
return request<void>("/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<Plug
body: JSON.stringify(payload),
});
}
export function listUsers(options?: {
limit?: number;
offset?: number;
}): Promise<UserListResponse> {
const params = new URLSearchParams({
limit: String(options?.limit ?? 50),
offset: String(options?.offset ?? 0),
});
return request<UserListResponse>(`/v1/users?${params.toString()}`);
}
export function createUser(payload: UserCreatePayload): Promise<CloudUser> {
return request<CloudUser>("/v1/users", {
method: "POST",
body: JSON.stringify(payload),
});
}
export function updateUser(userId: string, payload: UserUpdatePayload): Promise<CloudUser> {
return request<CloudUser>(`/v1/users/${encodeURIComponent(userId)}`, {
method: "PATCH",
body: JSON.stringify(payload),
});
}
export function resetUserPassword(userId: string, password: string): Promise<CloudUser> {
return request<CloudUser>(`/v1/users/${encodeURIComponent(userId)}/password`, {
method: "POST",
body: JSON.stringify({ password }),
});
}
export function revokeUserSessions(userId: string): Promise<void> {
return request<void>(`/v1/users/${encodeURIComponent(userId)}/sessions`, {
method: "DELETE",
});
}
-19
View File
@@ -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;
}
+2 -31
View File
@@ -1,14 +1,12 @@
<script setup lang="ts">
import { ref } from "vue";
import { API_BASE_URL, CloudApiError, login, storeToken } from "../api";
import { API_BASE_URL, CloudApiError, login } from "../api";
defineProps<{ message?: string }>();
const emit = defineEmits<{ (e: "authenticated"): void }>();
const useToken = ref(false);
const username = ref("");
const password = ref("");
const token = ref("");
const error = ref("");
const submitting = ref(false);
@@ -31,17 +29,6 @@ async function submitLogin() {
}
}
function submitToken() {
const value = token.value.trim();
if (!value) {
error.value = "paste a bearer token issued by the cloud control plane";
return;
}
storeToken(value);
token.value = "";
error.value = "";
emit("authenticated");
}
</script>
<template>
@@ -55,29 +42,13 @@ function submitToken() {
</div>
<div v-if="error" class="notice error" style="margin-bottom: 16px">{{ error }}</div>
<form v-if="!useToken" @submit.prevent="submitLogin">
<form @submit.prevent="submitLogin">
<label for="username">Username</label>
<input id="username" v-model="username" autocomplete="username" />
<label for="password" style="margin-top: 12px">Password</label>
<input id="password" v-model="password" type="password" autocomplete="current-password" />
<div class="actions">
<button class="primary" type="submit" :disabled="submitting">Sign in</button>
<button type="button" @click="useToken = true">Use API token</button>
</div>
</form>
<form v-else @submit.prevent="submitToken">
<label for="token">Bearer token</label>
<textarea
id="token"
v-model="token"
autocomplete="off"
spellcheck="false"
placeholder="paste a break-glass or compatibility token"
></textarea>
<div class="actions">
<button class="primary" type="submit">Connect</button>
<button type="button" @click="useToken = false">Use account login</button>
</div>
</form>
</div>
-51
View File
@@ -1,51 +0,0 @@
<script setup lang="ts">
import { ref } from "vue";
import { API_BASE_URL, storeToken } from "../api";
defineProps<{ rejectionMessage?: string }>();
const emit = defineEmits<{ (e: "submitted"): void }>();
const token = ref("");
const error = ref("");
function submit() {
const trimmed = token.value.trim();
if (!trimmed) {
error.value = "paste a bearer token issued by the cloud control plane";
return;
}
storeToken(trimmed);
error.value = "";
emit("submitted");
}
</script>
<template>
<div class="token-screen">
<h1>Cloud Console</h1>
<p>
Paste an operator bearer token scoped to the Cloud Control Plane at
<code>{{ API_BASE_URL }}</code>. The token is held in
<code>sessionStorage</code> only close this tab to discard it.
</p>
<div v-if="rejectionMessage" class="notice error" style="margin-bottom: 16px">
{{ rejectionMessage }}
</div>
<form @submit.prevent="submit">
<label for="token">Bearer token</label>
<textarea
id="token"
v-model="token"
autocomplete="off"
spellcheck="false"
placeholder="paste a token scoped at least to tasks:read, pool:read, plugins:read"
></textarea>
<div v-if="error" class="notice error" style="margin-top: 12px">
{{ error }}
</div>
<div class="actions">
<button class="primary" type="submit">Connect</button>
</div>
</form>
</div>
</template>
-174
View File
@@ -1,174 +0,0 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue";
import { LoaderCircle, RefreshCw, UserPlus } from "@lucide/vue";
import {
CloudApiError,
createUser,
listUsers,
resetUserPassword,
revokeUserSessions,
updateUser,
} from "../api";
import type { CloudUser, UserRole } from "../types";
const users = ref<CloudUser[]>([]);
const loading = ref(false);
const error = ref("");
const success = ref("");
const showCreate = ref(false);
const submitting = ref(false);
const passwordInputs = reactive<Record<string, string>>({});
const form = reactive({
username: "",
display_name: "",
role: "viewer" as UserRole,
password: "",
});
const roles: UserRole[] = ["viewer", "operator", "admin"];
function clearCreatePassword() {
form.password = "";
}
async function refresh() {
loading.value = true;
error.value = "";
try {
users.value = (await listUsers()).items;
} catch (err) {
error.value = describeError(err, "failed to load users");
} finally {
loading.value = false;
}
}
async function submitCreate() {
error.value = "";
success.value = "";
if (!form.username.trim() || !form.display_name.trim() || !form.password) {
error.value = "username, display name, role, and password are required";
return;
}
submitting.value = true;
try {
const created = await createUser({
username: form.username.trim(),
display_name: form.display_name.trim(),
role: form.role,
password: form.password,
});
form.username = "";
form.display_name = "";
form.role = "viewer";
clearCreatePassword();
showCreate.value = false;
success.value = `${created.username} was created and must change their password`;
await refresh();
} catch (err) {
clearCreatePassword();
error.value = describeError(err, "failed to create user");
} finally {
submitting.value = false;
}
}
async function saveUser(user: CloudUser) {
error.value = "";
success.value = "";
try {
const updated = await updateUser(user.id, {
role: user.role,
enabled: user.enabled,
display_name: user.display_name,
});
replaceUser(updated);
success.value = `updated ${updated.username}`;
} catch (err) {
error.value = describeError(err, "failed to update user");
await refresh();
}
}
async function resetPassword(user: CloudUser) {
const password = passwordInputs[user.id] || "";
if (!password) {
error.value = "enter a temporary password first";
return;
}
error.value = "";
try {
const updated = await resetUserPassword(user.id, password);
passwordInputs[user.id] = "";
replaceUser(updated);
success.value = `reset password for ${updated.username}`;
} catch (err) {
passwordInputs[user.id] = "";
error.value = describeError(err, "failed to reset password");
}
}
async function revokeSessions(user: CloudUser) {
error.value = "";
try {
await revokeUserSessions(user.id);
success.value = `revoked sessions for ${user.username}`;
} catch (err) {
error.value = describeError(err, "failed to revoke sessions");
}
}
function replaceUser(updated: CloudUser) {
users.value = users.value.map((user) => (user.id === updated.id ? updated : user));
}
function describeError(err: unknown, fallback: string): string {
return err instanceof CloudApiError || err instanceof Error ? err.message : fallback;
}
onMounted(refresh);
</script>
<template>
<div>
<div class="toolbar">
<h2>Users</h2>
<button :disabled="loading" @click="refresh"><RefreshCw :size="14" /> Refresh</button>
<button class="primary" @click="showCreate = !showCreate"><UserPlus :size="14" /> {{ showCreate ? "Close form" : "Create user" }}</button>
<span v-if="loading" class="muted"><LoaderCircle :size="14" class="loader" /> loading</span>
</div>
<div v-if="error" class="notice error" style="margin-bottom: 12px">{{ error }}</div>
<div v-if="success" class="notice success" style="margin-bottom: 12px">{{ success }}</div>
<div v-if="showCreate" class="panel">
<h3>Create user</h3>
<form @submit.prevent="submitCreate">
<div class="form-grid">
<div><label for="user-name">Username</label><input id="user-name" v-model="form.username" autocomplete="off" /></div>
<div><label for="display-name">Display name</label><input id="display-name" v-model="form.display_name" autocomplete="name" /></div>
<div><label for="user-role">Role</label><select id="user-role" v-model="form.role"><option v-for="role in roles" :key="role" :value="role">{{ role }}</option></select></div>
<div><label for="user-password">Initial password</label><input id="user-password" v-model="form.password" type="password" autocomplete="new-password" /></div>
</div>
<div class="toolbar" style="margin-top: 12px"><button class="primary" type="submit" :disabled="submitting">Create</button></div>
</form>
</div>
<div class="panel">
<table v-if="users.length">
<thead><tr><th>User</th><th>Role</th><th>Status</th><th>Last login</th><th>Actions</th></tr></thead>
<tbody>
<tr v-for="user in users" :key="user.id">
<td><strong>{{ user.display_name }}</strong><div class="dim">{{ user.username }}</div></td>
<td><select v-model="user.role"><option v-for="role in roles" :key="role" :value="role">{{ role }}</option></select></td>
<td><label><input v-model="user.enabled" type="checkbox" /> enabled</label><div v-if="user.must_change_password" class="dim">password change required</div></td>
<td class="dim">{{ user.last_login_at || "never" }}</td>
<td>
<div class="toolbar" style="margin: 0"><button @click="saveUser(user)">Save</button><input v-model="passwordInputs[user.id]" type="password" placeholder="temporary password" autocomplete="new-password" /><button @click="resetPassword(user)">Reset password</button><button @click="revokeSessions(user)">Revoke sessions</button></div>
</td>
</tr>
</tbody>
</table>
<div v-else class="muted">No user accounts found.</div>
</div>
</div>
</template>
-19
View File
@@ -25,26 +25,7 @@ services:
environment:
CLOUD_ENVIRONMENT: production
CLOUD_DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
CLOUD_PUBLIC_CREDENTIALS_JSON: ${CLOUD_PUBLIC_CREDENTIALS_JSON}
CLOUD_HOST_CREDENTIALS_JSON: ${CLOUD_HOST_CREDENTIALS_JSON}
CLOUD_ENROLLMENT_TOKENS_JSON: ${CLOUD_ENROLLMENT_TOKENS_JSON:-[]}
# Left disabled by default; the amcp.home.jerryyan.top deployment sets
# CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED=true in its own .env (not
# committed here) since it is the intended target for zero-token edge
# enrollment. See docs/CLOUD_DEPLOYMENT.md.
CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED: ${CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED:-false}
CLOUD_SCHEDULER_INTERVAL_SECONDS: ${CLOUD_SCHEDULER_INTERVAL_SECONDS:-1}
CLOUD_LEASE_REAPER_INTERVAL_SECONDS: ${CLOUD_LEASE_REAPER_INTERVAL_SECONDS:-5}
CLOUD_LEASE_DURATION_SECONDS: ${CLOUD_LEASE_DURATION_SECONDS:-60}
CLOUD_MAX_TASK_ATTEMPTS: ${CLOUD_MAX_TASK_ATTEMPTS:-3}
CLOUD_USER_SESSION_IDLE_SECONDS: ${CLOUD_USER_SESSION_IDLE_SECONDS:-28800}
CLOUD_USER_SESSION_ABSOLUTE_SECONDS: ${CLOUD_USER_SESSION_ABSOLUTE_SECONDS:-604800}
CLOUD_LOGIN_FAILURE_LIMIT: ${CLOUD_LOGIN_FAILURE_LIMIT:-5}
CLOUD_LOGIN_FAILURE_WINDOW_SECONDS: ${CLOUD_LOGIN_FAILURE_WINDOW_SECONDS:-900}
CLOUD_LOGIN_BLOCK_SECONDS: ${CLOUD_LOGIN_BLOCK_SECONDS:-900}
CLOUD_SESSION_COOKIE_SECURE: ${CLOUD_SESSION_COOKIE_SECURE:-true}
CLOUD_TRUST_PROXY_HEADERS: ${CLOUD_TRUST_PROXY_HEADERS:-false}
CLOUD_CONSOLE_STATIC_DIR: /app/console-static
ports:
- "${CLOUD_API_PORT:-8001}:8001"
depends_on:
-30
View File
@@ -26,22 +26,7 @@ services:
environment:
CLOUD_ENVIRONMENT: production
CLOUD_DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
CLOUD_PUBLIC_CREDENTIALS_JSON: ${CLOUD_PUBLIC_CREDENTIALS_JSON}
CLOUD_HOST_CREDENTIALS_JSON: ${CLOUD_HOST_CREDENTIALS_JSON}
CLOUD_ENROLLMENT_TOKENS_JSON: ${CLOUD_ENROLLMENT_TOKENS_JSON:-[]}
CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED: ${CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED:-false}
CLOUD_SCHEDULER_INTERVAL_SECONDS: ${CLOUD_SCHEDULER_INTERVAL_SECONDS:-1}
CLOUD_LEASE_REAPER_INTERVAL_SECONDS: ${CLOUD_LEASE_REAPER_INTERVAL_SECONDS:-5}
CLOUD_LEASE_DURATION_SECONDS: ${CLOUD_LEASE_DURATION_SECONDS:-60}
CLOUD_MAX_TASK_ATTEMPTS: ${CLOUD_MAX_TASK_ATTEMPTS:-3}
CLOUD_USER_SESSION_IDLE_SECONDS: ${CLOUD_USER_SESSION_IDLE_SECONDS:-28800}
CLOUD_USER_SESSION_ABSOLUTE_SECONDS: ${CLOUD_USER_SESSION_ABSOLUTE_SECONDS:-604800}
CLOUD_LOGIN_FAILURE_LIMIT: ${CLOUD_LOGIN_FAILURE_LIMIT:-5}
CLOUD_LOGIN_FAILURE_WINDOW_SECONDS: ${CLOUD_LOGIN_FAILURE_WINDOW_SECONDS:-900}
CLOUD_LOGIN_BLOCK_SECONDS: ${CLOUD_LOGIN_BLOCK_SECONDS:-900}
CLOUD_SESSION_COOKIE_SECURE: ${CLOUD_SESSION_COOKIE_SECURE:-true}
CLOUD_TRUST_PROXY_HEADERS: ${CLOUD_TRUST_PROXY_HEADERS:-false}
CLOUD_CONSOLE_STATIC_DIR: /app/console-static
ports:
- "${CLOUD_API_PORT:-8001}:8001"
depends_on:
@@ -64,23 +49,8 @@ services:
command: ["device-host-agent"]
environment:
HOST_AGENT_CONTROL_PLANE_URL: http://cloud-api:8001
HOST_AGENT_HOST_ID: ${HOST_AGENT_HOST_ID}
HOST_AGENT_TOKEN: ${HOST_AGENT_TOKEN}
HOST_AGENT_ENROLLMENT_TOKEN: ${HOST_AGENT_ENROLLMENT_TOKEN:-}
HOST_AGENT_IDENTITY_PATH: ${HOST_AGENT_IDENTITY_PATH:-/app/tasks/host_identity.json}
HOST_AGENT_LOCAL_ACCOUNT_PATH: ${HOST_AGENT_LOCAL_ACCOUNT_PATH:-/app/tasks/host_local_account.json}
HOST_AGENT_DISPLAY_NAME: ${HOST_AGENT_DISPLAY_NAME:-}
HOST_AGENT_HEARTBEAT_INTERVAL_SECONDS: ${HOST_AGENT_HEARTBEAT_INTERVAL_SECONDS:-30}
HOST_AGENT_POLL_TIMEOUT_SECONDS: ${HOST_AGENT_POLL_TIMEOUT_SECONDS:-20}
HOST_AGENT_RETRY_BACKOFF_SECONDS: ${HOST_AGENT_RETRY_BACKOFF_SECONDS:-1}
HOST_AGENT_MAX_RETRY_BACKOFF_SECONDS: ${HOST_AGENT_MAX_RETRY_BACKOFF_SECONDS:-30}
HOST_AGENT_MAX_RETRY_ATTEMPTS: ${HOST_AGENT_MAX_RETRY_ATTEMPTS:-5}
AI_PLANNER_ENABLED: ${AI_PLANNER_ENABLED:-false}
AI_PLANNER_PROVIDER: ${AI_PLANNER_PROVIDER:-anthropic}
AI_PLANNER_MODEL: ${AI_PLANNER_MODEL:-}
AI_PLANNER_TIMEOUT_SECONDS: ${AI_PLANNER_TIMEOUT_SECONDS:-30}
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
volumes:
- ${HOST_AGENT_TASKS_PATH:-./tasks}:/app/tasks
depends_on:
+113 -119
View File
@@ -10,24 +10,29 @@ uv sync --locked --all-packages
## Local SQLite
SQLite is intended for local development and tests with one Cloud API process.
Configure public and host credentials even in local mode because anonymous
development access cannot authorize a host identity.
The Cloud API uses persistent user sessions for human access and a Host-generated
secret for later Host operations; no static bearer credential configuration is
required.
```powershell
$env:CLOUD_ENVIRONMENT = "local"
$env:CLOUD_DATABASE_URL = "sqlite:///cloud/cloud.sqlite3"
$env:CLOUD_PUBLIC_CREDENTIALS_JSON = '[{"principal_id":"local-sdk","token":"replace-public-token","scopes":["tasks:submit","tasks:read","pool:read","plugins:read","plugins:admin"]}]'
$env:CLOUD_HOST_CREDENTIALS_JSON = '[{"principal_id":"local-host","token":"replace-host-token","scopes":[],"host_id":"host-local"}]'
$env:CLOUD_SESSION_COOKIE_SECURE = "false"
uv run --package device-cloud-api device-cloud-api --host 127.0.0.1 --port 8001
```
In a second terminal, start the Host Agent with the matching host identity and
token:
Create the first administrator interactively, then open the Console and sign
in with that account:
```bash
uv run --package device-cloud-api device-cloud-admin users create --username admin --display-name "Local Administrator" --role admin
```
In a second terminal, configure the local Host Agent to reach this Cloud API:
```powershell
$env:HOST_AGENT_CONTROL_PLANE_URL = "http://127.0.0.1:8001"
$env:HOST_AGENT_HOST_ID = "host-local"
$env:HOST_AGENT_TOKEN = "replace-host-token"
uv run --package device-host-agent device-host-agent setup
uv run --package device-host-agent device-host-agent
```
@@ -41,66 +46,23 @@ defaults to `./tasks`.
The Host Agent only initiates outbound HTTP requests. It does not expose an
inbound port.
## Managed Edge Enrollment
New edge installations do not need a pre-coordinated Host or device ID. The
Cloud API accepts configured one-time enrollment credentials:
```powershell
$env:CLOUD_ENROLLMENT_TOKENS_JSON = '[{"principal_id":"edge-installer","token":"replace-with-a-long-random-one-time-token"}]'
```
On the edge Host, omit `HOST_AGENT_HOST_ID` and `HOST_AGENT_TOKEN` and provide
the enrollment token only for the first successful enrollment:
```bash
export HOST_AGENT_CONTROL_PLANE_URL="https://cloud.example.com"
export HOST_AGENT_ENROLLMENT_TOKEN="replace-with-a-long-random-one-time-token"
export HOST_AGENT_IDENTITY_PATH="tasks/host_identity.json"
export HOST_AGENT_DISPLAY_NAME="Edge Mac 01"
uv run --package device-host-agent device-host-agent
```
Before its first request the Host Agent creates `HOST_AGENT_IDENTITY_PATH` with
an instance identifier and long-lived random Host secret. The cloud consumes
the enrollment token, assigns `host_id`, stores only credential digests, and
returns the assigned ID. The Host Agent then enrolls each record from
`tasks/device_config.sqlite3`, stores its cloud-assigned `device_id` in that
database, connects the resulting devices, and starts heartbeat/claim loops.
Keep the identity file and device configuration database on persistent edge
storage with permissions limited to the service account. The identity file is
a bearer secret: do not put it in an image, repository, log, or general backup.
After successful enrollment, remove `HOST_AGENT_ENROLLMENT_TOKEN` from the edge
environment. An intact identity file is sufficient for restart; if only a
device mapping is lost, device enrollment reconstructs the same cloud ID.
Enrollment tokens are one-time even when they remain in Cloud API environment
configuration: their consumed digest is stored in the database. Reusing a token
for another edge instance returns a conflict. Create a distinct token for every
edge installation.
Explicit `HOST_AGENT_HOST_ID` plus `HOST_AGENT_TOKEN` takes precedence and keeps
the previous legacy behavior, including locally selected device IDs. This is
the rollback and staged-migration path for existing deployments.
## Self-Service Edge Enrollment (Zero-Token)
## Direct Edge Enrollment
The Host Agent's default `HOST_AGENT_CONTROL_PLANE_URL` is
`https://amcp.home.jerryyan.top`. This is a single-operator home deployment
default; override the environment variable for local/dev/test runs pointed at
a different Cloud API.
When `HOST_AGENT_ENROLLMENT_TOKEN` is not set and no cached identity exists,
the Host Agent enrolls with no bearer credential at all. The Cloud API only
accepts that request when `CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED=true`
(default `false`); a configured enrollment token, if presented, always takes
priority over self-service. This trades away any approval step: **any caller
that can reach the control-plane URL can register itself as a new Host.**
There is no rate limiting or throttling on this path by design — the intended
mitigation is network-perimeter control (firewall/reverse-proxy access to the
URL), not an in-process limiter. Only enable the flag on a deployment where
that network boundary is enforced.
When no cached identity exists, the Host Agent creates a random instance
identifier and Host secret, registers directly with the Cloud API, persists the
assigned `host_id`, and uses that secret for all later device enrollment,
heartbeat, claim, renewal, and result calls. The Cloud stores only the secret
digest. There is no configured enrollment-token or static Host-credential path.
This trades away any approval step: **any caller that can reach the control
plane can register itself as a new Host.** There is no rate limiting or
throttling on this path by design; restrict access at the firewall or reverse
proxy before exposing the endpoint.
Before the Host Agent's first unattended start, create the one-time local
operator account interactively:
@@ -124,6 +86,54 @@ docker compose run --rm host-agent device-host-agent setup
docker compose up -d
```
Keep `HOST_AGENT_IDENTITY_PATH`, the local-account file, and
`tasks/device_config.sqlite3` on persistent storage with permissions limited to
the service account. The identity file is a bearer secret: do not put it in an
image, repository, log, or general backup.
### Local Web Console
The Host Agent can optionally serve a small local-only web console on the
edge machine: heartbeat/enrollment status, registered local devices, current
assignment progress, local device add/edit/remove, a local account password
change, and recent assignment/heartbeat history. It authenticates with the
same local account created by `device-host-agent setup` above — there is no
separate console credential.
```text
HOST_AGENT_CONSOLE_ENABLED=false
HOST_AGENT_CONSOLE_BIND_HOST=127.0.0.1
HOST_AGENT_CONSOLE_PORT=8765
HOST_AGENT_CONSOLE_ALLOW_NON_LOOPBACK=false
HOST_AGENT_CONSOLE_SESSION_TTL_SECONDS=43200
HOST_AGENT_CONSOLE_HISTORY_LIMIT=200
```
- `HOST_AGENT_CONSOLE_ENABLED` — starts the console when `true`; disabled by
default, so existing deployments see no new listening port.
- `HOST_AGENT_CONSOLE_BIND_HOST` — the address the console binds to; defaults
to loopback-only.
- `HOST_AGENT_CONSOLE_PORT` — the TCP port the console listens on.
- `HOST_AGENT_CONSOLE_ALLOW_NON_LOOPBACK` — required opt-in before
`HOST_AGENT_CONSOLE_BIND_HOST` may be a non-loopback address; the Host
Agent refuses to start otherwise.
- `HOST_AGENT_CONSOLE_SESSION_TTL_SECONDS` — sliding idle timeout, in seconds,
for an authenticated console session.
- `HOST_AGENT_CONSOLE_HISTORY_LIMIT` — number of recent assignment/heartbeat
entries the console retains before pruning older ones.
Treat `HOST_AGENT_CONSOLE_ALLOW_NON_LOOPBACK` as an explicit,
operator-accepted risk: the console has no built-in TLS and no rate
limiting, so a non-loopback bind exposes an unencrypted login form to
whatever network can reach that port. To reach the console from another
machine instead, keep it bound to loopback and open an SSH local
port-forward to the edge machine:
```bash
ssh -L 8765:127.0.0.1:8765 user@edge-host
# then open http://127.0.0.1:8765 from the local browser
```
## PostgreSQL Deployment
Start from `.env.example`, replace every `change-me-*` value, and keep the
@@ -151,10 +161,10 @@ the pytest suite (`-m "not integration"`), builds the image from the same
`device-host-agent --help`), and pushes `<REGISTRY>/<IMAGE_NAME>:<BUILD_NUMBER>-<git short sha>`
plus `:latest` to the configured registry.
`compose.deploy.yaml` is the same three-service stack as `compose.yaml`
except `cloud-api` and `host-agent` reference `image:` instead of `build:`.
Set `REGISTRY`, `IMAGE_NAME`, and `IMAGE_TAG` (see `.env.example`) to the tag
Jenkins published, then deploy without a local build step:
`compose.deploy.yaml` contains only PostgreSQL and the Cloud API; Host Agents
run at their edge sites rather than beside the Cloud API. It references the
fixed Jenkins registry image and interpolates only `IMAGE_TAG` (see
`.env.example`). Deploy without a local build step:
```bash
docker compose -f compose.deploy.yaml pull
@@ -172,36 +182,22 @@ uv run --package device-cloud-api device-cloud-api --host 0.0.0.0 --port 8001
```
Production startup requires `CLOUD_ENVIRONMENT=production`, a current schema,
and at least one configured bearer credential.
and HTTPS before browser sessions are exposed. It does not require credential
JSON. Create the first administrator interactively after startup, before
opening the Console to operators.
## Credentials And Scopes
## Authentication And Host Identities
`CLOUD_PUBLIC_CREDENTIALS_JSON` is a JSON array of public API principals. Grant
only the scopes required by each integration:
Cloud users are the human authorization boundary. Their `viewer`, `operator`,
and `admin` roles map to the existing API scopes, and the browser sends an
`HttpOnly` session cookie plus CSRF proof for unsafe operations. The deployment
does not accept `CLOUD_PUBLIC_CREDENTIALS_JSON`,
`CLOUD_HOST_CREDENTIALS_JSON`, or `CLOUD_ENROLLMENT_TOKENS_JSON`.
- `tasks:submit`: submit tasks.
- `tasks:read`: read task status and failure metadata.
- `pool:read`: list hosts and devices.
- `plugins:read`: list installed plugin registrations.
- `plugins:admin`: register installed plugin entry points.
`CLOUD_HOST_CREDENTIALS_JSON` contains Host Agent principals. Every entry must
include exactly one `host_id`; its token is valid only for heartbeat, claim,
renewal, and result operations for that host.
`CLOUD_ENROLLMENT_TOKENS_JSON` contains bootstrap principals with only
`principal_id` and `token`. These credentials cannot submit tasks, read the
pool, or operate as a Host; they can only create one durable Host binding.
Use high-entropy values generated by the deployment secret manager.
`CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED` (default `false`) additionally accepts
Host enrollment requests with no bearer token at all — see
[Self-Service Edge Enrollment](#self-service-edge-enrollment-zero-token).
Do not place bearer tokens in command history, image layers, Compose files, or
logs. Use environment injection or the deployment platform's secret manager.
Rotate a token by deploying the updated Cloud API credential set and Host Agent
configuration together.
A fresh Host sends its generated candidate secret only during direct
registration. The Cloud stores its digest and returns a `host_id`; later Host
operations use that secret and are strictly bound to the returned `host_id`.
Protect the persisted Host identity file as a bearer secret.
Dynamically enrolled Host credentials are stored as digests in the cloud
database. This release exposes repository-level revocation rather than a public
@@ -230,12 +226,10 @@ PY
## Cloud Console (Web UI)
The repository ships an independent Vue 3 + Vite SPA at `cloud-console/` that
renders task history, devices, hosts, plugins, and the user directory. Human
operators sign in with a username and password; the Cloud API creates an
expiring, revocable `HttpOnly` session cookie and uses a separate CSRF
cookie/header for writes. Existing bearer tokens remain available through the
Console's explicit **Use API token** action and for SDK, Host Agent, and
automation compatibility.
renders task history, devices, hosts, and plugins. Human operators sign in with
a username and password; the Cloud API creates an expiring, revocable
`HttpOnly` session cookie and uses a separate CSRF cookie/header for writes.
The Console has no bearer-token fallback or user-directory view.
### HTTPS and session configuration
@@ -258,7 +252,9 @@ CLOUD_TRUST_PROXY_HEADERS=false
```
Set `CLOUD_TRUST_PROXY_HEADERS=true` only when a trusted proxy overwrites
`X-Forwarded-For` before requests reach the Cloud API.
`X-Forwarded-For` before requests reach the Cloud API. This affects the
client-address bucket used for login throttling; the default is safe when the
application is reached directly.
### Create and recover administrator accounts
@@ -281,11 +277,11 @@ docker compose exec cloud-api device-cloud-admin users revoke-sessions --usernam
```
Roles are fixed: `viewer` can read tasks/pool/plugins; `operator` additionally
submits tasks; `admin` has unrestricted Cloud API access and manages users.
Administrators create users, reset passwords, change roles, disable accounts,
and revoke sessions from the **Users** Console view. New and reset users must
change their temporary password before accessing other resources, and the API
will not disable or demote the last enabled administrator.
submits tasks; `admin` has unrestricted Cloud API access. The administration
CLI creates users, resets passwords, changes roles, enables accounts, and
revokes sessions. New and reset users must change their temporary password
before accessing other resources, and the API will not disable or demote the
last enabled administrator.
### Configure the CORS allow-list
@@ -328,22 +324,20 @@ at build time. The deployed origin must be in `CLOUD_CONSOLE_CORS_ORIGINS`.
### Same-origin deployment (baked into the Cloud API image)
The Jenkins-built Docker image already carries the SPA at `/app/console-static`,
and `compose.yaml` / `compose.deploy.yaml` set
`CLOUD_CONSOLE_STATIC_DIR=/app/console-static` on the `cloud-api` service. In
this mode the Cloud API itself serves the console at `/console/` (visiting `/`
307-redirects there). Put that origin behind an HTTPS reverse proxy, then open
for example `https://cloud.example.com/` directly — no separate dev server, no
static host, and no CORS allow-list are needed because the SPA and API share one
origin.
The Jenkins-built Docker image already carries the SPA at `/app/console-static`
and sets `CLOUD_CONSOLE_STATIC_DIR` in the image itself. The Cloud API serves
the Console at `/console/` (visiting `/` 307-redirects there). Put that origin
behind an HTTPS reverse proxy, then open for example
`https://cloud.example.com/` directly — no separate dev server, static host, or
CORS allow-list is needed because the SPA and API share one origin.
The SPA shell (`index.html`, JS, CSS) is served without credentials by design
so it can render the login page. All `/v1/*` resource calls remain scope-gated,
and unsafe cookie-authenticated calls require CSRF proof. The browser receives
only the non-secret CSRF value; it never receives the `HttpOnly` session secret.
To opt out (e.g. for local development where you run `npm run dev`), leave
`CLOUD_CONSOLE_STATIC_DIR` unset. The mount is conditional on that env var.
Source-based local development leaves `CLOUD_CONSOLE_STATIC_DIR` unset; the
mount remains conditional on that image-provided setting.
Jenkins build args (`NODE_IMAGE`, `NPM_REGISTRY`, `UV_IMAGE`, `APT_MIRROR`,
`UV_INDEX_URL`) default to CN mirrors so builds don't time out pulling from
@@ -406,9 +400,9 @@ For rollback:
1. Stop all Host Agents and the Cloud API.
2. Back up PostgreSQL or the SQLite database file.
3. Before rolling back to a release without enrollment support, provision
temporary static Host credentials for every managed edge that must continue
operating. Stop those Host Agents and set their explicit Host ID/token.
3. Before rolling back to an image that still requires static credentials,
restore that image's matching deployment configuration and provision the
required legacy credentials outside this release's Compose contract.
4. If the previous application version cannot use the current schema, run the
tested downgrade while no application process is connected:
@@ -417,7 +411,7 @@ For rollback:
```
5. Restore the previous application image or checkout and start the Cloud API.
6. Verify `/health/ready`, then restart Host Agents with credentials compatible
6. Verify `/health/ready`, then restart Host Agents with identities compatible
with the restored Cloud API.
Downgrading revision 0002 removes dynamic credential bindings and durable
+40 -12
View File
@@ -376,10 +376,8 @@ uv run --package device-host-agent device-host-agent setup
`HOST_AGENT_CONTROL_PLANE_URL` 默认已固定为 `https://amcp.home.jerryyan.top`
仅在连接其他云端环境(如本地/测试用的 `cloud-api`)时才需要覆盖该变量。未设置
`HOST_AGENT_ENROLLMENT_TOKEN` 时,Host Agent 会直接向云端发起零 token 自助注册
(需要云端 `CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED=true`);若仍持有云端管理员
签发的一次性 enrollment token,也可以继续设置 `HOST_AGENT_ENROLLMENT_TOKEN`
走原有的 token 注册路径:
缓存身份不存在时,Host Agent 会直接向云端注册,由云端返回 `host_id`;后续运行
使用本地持久化的随机 Host secret。无需配置静态 Host 或 enrollment token:
```bash
export HOST_AGENT_IDENTITY_PATH="tasks/host_identity.json"
@@ -394,17 +392,47 @@ uv run --package device-host-agent device-host-agent
首次启动顺序为:持久化候选 Host secret、向云端换取 `host_id`、为每个本地设备
换取 `device_id`、保存映射、连接 WDA、发送 heartbeat、开始 long-poll 领取任务。
Host Agent 不监听入站端口。若使用了一次性 enrollment token,成功后可以将其从
边缘环境移除;但必须保留并保护 `tasks/host_identity.json`
Host Agent 不监听入站端口。必须保留并保护 `tasks/host_identity.json`
`tasks/device_config.sqlite3`;前者等同于 Host bearer credential。
零 token 自助注册意味着任何能访问该云端地址的设备都能自行注册成为 Host,没有
审批环节,也没有限流保护;这一取舍依赖网络边界(防火墙/反向代理)而非应用层
限制,仅应在受控网络中开启 `CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED`
直接注册意味着任何能访问该云端地址的设备都能自行注册成为 Host,没有审批环节,
也没有限流保护;这一取舍依赖网络边界(防火墙/反向代理)而非应用层限制。
需要回滚到静态模式时,停止 Host Agent,由云端管理员配置匹配的静态 Host
credential,然后显式设置 `HOST_AGENT_HOST_ID``HOST_AGENT_TOKEN`。这两个变量
同时存在时优先于 enrollment state。
### 启用本地 Web Console(可选)
Host Agent 内置一个默认关闭的本地 Web Console,用于在这台 Mac 上直接查看和管理
正在运行的 Host,无需 SSH 进去读原始状态文件。启用时追加环境变量后再启动:
```bash
export HOST_AGENT_CONSOLE_ENABLED="true"
uv run --package device-host-agent device-host-agent
```
不要修改 `HOST_AGENT_CONSOLE_BIND_HOST`,保持默认回环地址 `127.0.0.1`;Console
启动后在同一台 Mac 上打开:
```
http://127.0.0.1:8765
```
`8765``HOST_AGENT_CONSOLE_PORT` 的默认值(见
`apps/device-host-agent/host_agent/config.py`)。登录使用与
`uv run --package device-host-agent device-host-agent setup` 创建的同一个本地账号,
没有单独的 Console 账号体系。
登录后可以看到:
- 状态仪表盘:最近一次 heartbeat 结果与时间、enrollment/identity 状态、本地登记
设备及其连接状态、当前 assignment 执行进度。
- 本地设备管理:新增、编辑、删除登记在这台 Host 上的设备,改动会立即在运行中的
`DeviceManager` 上生效,无需重启 Host Agent。
- 修改密码:更新本地操作账号密码,需要先输入当前密码。
- 最近历史:近期 assignment 与 heartbeat 的执行记录。
完整的 `HOST_AGENT_CONSOLE_*` 环境变量列表(端口、非回环 bind 的显式 opt-in、
session TTL、历史记录条数上限等)参见 `docs/CLOUD_DEPLOYMENT.md`;生产/远程场景下
应优先使用 SSH 端口转发访问该 Console,而不是直接把它暴露到非回环地址。
## 10. 多设备与端口
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-13
@@ -0,0 +1,71 @@
## Context
`apps/device-host-agent` is currently a headless asyncio process (`HostAgentApplication.run_async` in `host_agent/app.py`): a heartbeat task and a claim/execute/report loop, both driven purely by outbound HTTP calls to the Cloud API. It has no inbound listener of any kind, and its `pyproject.toml` declares only `httpx`, `device-agent-runtime`, and `device-cloud-platform` as direct dependencies — no web framework.
The root `device-agent-runtime` package (a workspace dependency of `device-host-agent`) already depends on `fastapi>=0.115.0` and `uvicorn[standard]>=0.30.0` (used by `api/rest.py` for the local single-machine Runtime API and by `console/`'s backend). Those packages are therefore already present in the resolved `uv.lock` and importable from `device-host-agent` today, even though `device-host-agent` does not declare them directly.
Local operator-facing state currently lives in three separate stores on the edge machine: `host_agent/local_account.py::LocalAccountStore` (PBKDF2 credential), `host_agent/identity.py::HostIdentityStore` (enrollment identity/host_id), and `storage/device_config.py::DeviceConfigStore` (locally registered devices, SQLite). None of it is visible or editable except by reading/editing these files directly or via `device-host-agent setup` (account creation only). `openspec/changes/edge-host-self-enrollment` (not yet implemented) defines the CLI-only first-account-creation flow; this change does not alter that requirement.
## Goals / Non-Goals
**Goals:**
- Give an operator on the edge machine a same-host web page to see heartbeat/enrollment/device/assignment status at a glance, and to perform the small set of actions that currently require hand-editing files: add/edit/remove a local device, change the local account password, review recent assignment/heartbeat outcomes.
- Keep the default deployment posture unchanged: console off by default; when enabled, bound to loopback only unless the operator explicitly opts into a wider bind address.
- Reuse the existing local account as the only credential — no second user/credential system.
- Server-rendered HTML, not a SPA: no new frontend build tooling, no JS framework, minimal inline `fetch()` calls only for the few sections that benefit from polling refresh (heartbeat status, current assignment progress).
**Non-Goals:**
- No TLS termination, reverse proxy, or certificate management built into the Host Agent — LAN exposure beyond loopback is an explicit, documented operator opt-in with the risk called out, not a feature this change makes safe by default.
- No multi-user accounts, roles, or audit logging beyond the bounded local history — the existing `local_account.py` model is single-account, and this change keeps it that way.
- No syncing of the new local assignment/heartbeat history to the Cloud Console; it is a purely local, best-effort operational aid, not a system of record (the Cloud API/`cloud-console` already own durable task/attempt history).
- No change to the outbound `host-agent-protocol` capability or to how devices are enrolled with the cloud; the console only calls the same local `DeviceConfigStore`/enrollment client code paths that `host_agent/app.py::_configured_device_manager` already uses at startup.
## Decisions
### Reuse FastAPI + Starlette's `HTMLResponse`, not a new micro-framework, not Jinja2
FastAPI/Starlette are already transitively resolved via `device-agent-runtime`. Adding them as **explicit** direct dependencies of `device-host-agent` (rather than relying on the transitive edge) is the only `pyproject.toml` change needed — no new third-party web framework enters the dependency graph. Pages are built with small Python functions returning `HTMLResponse(content=...)` from hand-written f-string templates with `html.escape()` on every interpolated value (no Jinja2: the page count is small — login, dashboard, devices, history — and a templating engine is unjustified surface area for a handful of server-rendered fragments). This matches the user's explicit direction: server-rendered pages, not the `console/`/`cloud-console/` SPA pattern, and not a new heavyweight dependency.
Alternative considered: `http.server`/stdlib-only implementation. Rejected — would duplicate routing, form parsing, and cookie handling that FastAPI/Starlette already provide for free given they're already in the dependency graph.
### Run the console server in the same asyncio loop as the heartbeat/claim loop
`HostAgentApplication.run_async` gains a third concurrent task (alongside `heartbeat_task` and the claim/process loop) that runs a `uvicorn.Server` configured with `install_signal_handlers=False` when `config.console_enabled`. It is started and stopped using the same `stop_requested`/`finally` shutdown sequence already used for the heartbeat task, so `Ctrl+C`/service-stop behavior is unchanged when the console is off (the default) and cleanly tears down the extra task when it's on.
Alternative considered: separate process/thread running its own event loop. Rejected — the console needs live references to the same `DeviceManager`, `HostAgentClient`, and in-flight assignment state that the main loop owns; a separate process would need its own IPC layer to read that state, which is unjustified complexity for a same-host admin page.
### Cookie session issued at login, not per-request Basic Auth
Login is a normal HTML form POST to `/login` that calls `LocalAccountStore.verify()` once (PBKDF2, 600 000 iterations — intentionally expensive, on the order of ~100ms+, which is fine for one login but would be a real cost if paid on every polled `fetch()`). On success the server issues a random opaque session token (`secrets.token_urlsafe`), stored in an in-memory `dict[str, SessionState]` (process-local; a restart invalidates all sessions, which is acceptable for a single-operator local admin page), and sets it as an `HttpOnly`, `SameSite=Strict`, `Secure`-when-not-loopback session cookie with a sliding expiry (e.g. 12h idle timeout). All other routes require a valid session and redirect to `/login` otherwise. This mirrors the cookie+session shape already validated in the sibling `cloud-console-user-authentication` change, applied here to a single local account instead of a multi-user table.
### CSRF token bound to the session, required on all mutating requests
Because authentication is a cookie the browser attaches automatically, every state-changing endpoint (device add/edit/remove, password change, logout) requires a per-session CSRF token — rendered into the page/forms and also required as a request header on the small number of `fetch()`-based mutations — checked against the value stored alongside the session. Read-only status/history polling endpoints do not require it.
### Local device CRUD updates the live `DeviceManager` in the same request, not just `DeviceConfigStore`
`device/manager.py::DeviceManager` already exposes `register_device`/`unregister_device`. The console's device-CRUD handlers call the same sequence `host_agent/app.py::_configured_device_manager` uses at startup (persist to `DeviceConfigStore`, call `enrollment_client.enroll_device(...)` when `config.enrollment_managed`, then `manager.register_device(...)`) so a device added or removed through the web page takes effect immediately, without requiring a Host Agent restart. This existing sequence is extracted into a small shared helper used by both the startup path and the new console routes, rather than duplicated.
### New bounded local history store for recent assignments/heartbeats
The Host Agent currently discards assignment outcomes once reported to the control plane and keeps no heartbeat history at all. A new local-only SQLite table (e.g. `tasks/host_console_history.sqlite3`, following the existing `storage.device_config` pattern of a small dedicated SQLite file under `tasks/`) records the last N (configurable, default e.g. 200) assignment results and heartbeat syncs. `AssignmentProcessor` and `HeartbeatSynchronizer` accept an optional recorder callback (no-op when the console is disabled, so there is zero overhead in the default configuration) that appends a row after each terminal report / heartbeat sync; the console's history page reads from this table. Retention is enforced by pruning beyond the configured cap on write, not by a separate cron/background task.
### Config additions, all opt-in and backward compatible
`HostAgentConfig` gains: `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` (12h), `console_history_limit: int = 200` — all with matching `HOST_AGENT_CONSOLE_*` environment variables following the existing `_positive_float`/`_positive_int` validation helpers in `config.py`. Loading raises `HostAgentConfigurationError` if `console_bind_host` resolves to a non-loopback address while `console_allow_non_loopback` is not set, so the risky configuration requires two explicit affirmative settings, not one.
## Risks / Trade-offs
- **[Risk] No TLS by default; a non-loopback bind sends the session cookie and form-posted password over cleartext HTTP on the LAN.** → Mitigation: loopback-only by default; non-loopback requires the explicit second opt-in flag; document the recommended alternative (SSH local port-forward to keep the console loopback-only while still reachable remotely) in `docs/CLOUD_DEPLOYMENT.md`/`docs/MACOS_IPHONE_SETUP.md` rather than building TLS support into this change.
- **[Risk] In-memory session store means every Host Agent process restart forces re-login.** → Accepted: restarts are infrequent for a background service and re-login is a low-friction PBKDF2 verify; avoids adding a persistent session store and its own cleanup/expiry code for a single-operator page.
- **[Risk] Blocking SQLite calls (`DeviceConfigStore`, new history store) on the same asyncio loop that runs heartbeat/claim could add latency under concurrent console use.** → Mitigation: wrap console route handlers' store calls in `asyncio.to_thread`, consistent with the existing `asyncio.to_thread(self.executor.execute, ...)` pattern in `host_agent/lease.py`; SQLite operations here are small and infrequent (one operator, occasional page loads) so this is a low-severity concern even without the wrapping, but the pattern costs nothing to apply consistently.
- **[Trade-off] Hand-written HTML via f-strings instead of a templating engine is more verbose per-page and pushes escaping discipline onto the author.** → Mitigation: a single small `escape()`-wrapping helper used for every interpolated value, and a lint/review checklist item (covered in tasks.md) rather than relying on an engine's autoescaping; the page count is small enough that this remains manageable.
- **[Trade-off] New local-only history duplicates, in miniature, information the Cloud Console already owns durably.** → Accepted: this history exists specifically for operators without (or before) Cloud Console access, or debugging when the control plane itself is unreachable; it is explicitly not a system of record (Non-Goals).
## Migration Plan
1. Add `fastapi`/`uvicorn[standard]` as explicit direct dependencies in `apps/device-host-agent/pyproject.toml` (versions already pinned in the shared `uv.lock` via the transitive edge — no version drift expected).
2. Add the new `HostAgentConfig` fields with the safe defaults above; existing deployments that don't set any `HOST_AGENT_CONSOLE_*` variable see no behavior change.
3. Implement the console module and wire its optional startup/shutdown into `HostAgentApplication.run_async`, gated on `config.console_enabled`.
4. Add the new bounded history store and the optional recorder hooks to `AssignmentProcessor`/`HeartbeatSynchronizer`, no-op by default.
5. Document how to enable the console (env vars, loopback-only default, SSH port-forward recommendation for remote access) in `docs/CLOUD_DEPLOYMENT.md` and `docs/MACOS_IPHONE_SETUP.md`.
6. Rollback: unset/leave `HOST_AGENT_CONSOLE_ENABLED` at its default `false`. No schema or state migration is introduced for existing stores (`DeviceConfigStore`, `LocalAccountStore`, `HostIdentityStore` are all read via their existing APIs, unchanged); the new history SQLite file is purely additive and can be deleted with no effect on Host Agent operation.
## Open Questions
- Exact default/max value for `console_history_limit` (row cap) — proposed default 200, may need tuning once real usage is observed.
- Whether a future change should let the Cloud Console optionally pull this local history for remote debugging (explicitly out of scope here; would need a new outbound protocol surface and its own review).
- Whether single-shared-account is sufficient long-term for edge machines with multiple physical operators, or whether that should be revisited alongside any future change to `local_account.py`'s single-account model.
@@ -0,0 +1,25 @@
## Why
`apps/device-host-agent` is a headless outbound worker: it has no HTTP server, no static assets, and no way to inspect or manage a running instance except by reading log output or editing `tasks/*.sqlite3`/`tasks/*.json` files by hand on the edge machine. Operators installing a new Host on an edge device (Mac/iPhone rig, etc.) currently must use `device-host-agent setup` (terminal-only, `getpass`) to create the local account, and have no local way to see heartbeat/enrollment status, review or edit locally registered devices, or check why the last assignment failed, without SSH-ing in and reading raw state files or cross-referencing the Cloud Console (which only shows what the Host last reported, not local-only state like unenrolled devices). A minimal local web page closes that operational gap.
## What Changes
- Add an embedded, server-rendered local web console to the Host Agent process: plain HTML responses from a lightweight HTTP server (no separate frontend build, no SPA framework), with a handful of endpoints returning small JSON fragments that a few inline `<script>` blocks poll to refresh sections of the page without a full reload.
- Web console covers: status/monitoring (heartbeat/last-seen, enrollment/identity state, registered local devices and their status, current assignment/execution progress, sanitized effective config such as `control_plane_url` and `host_id` with `token` never rendered), local device management (add/edit/remove entries in `storage/device_config.py`'s `DeviceConfigStore`), account settings (change the local account password in place; creating the *first* account remains the job of `device-host-agent setup`), and recent assignment/heartbeat history (a new bounded local log, since the Host Agent does not currently retain any local record of past assignments after reporting results to the control plane).
- New `HostAgentConfig` fields to gate and bind the console: disabled by default, and when enabled defaults to binding `127.0.0.1` only; binding to a non-loopback address is possible but requires an explicit opt-in and is treated as a documented, operator-accepted risk (no built-in TLS or rate limiting — see design.md threat model).
- Web login reuses the existing `host_agent/local_account.py` PBKDF2 credential (same account as `device-host-agent setup` creates/resets); no second credential store.
- `device-host-agent` gains a new optional dependency on a minimal ASGI/WSGI server library to host the embedded HTTP server; `HostAgentApplication` starts/stops it alongside the existing heartbeat and claim loop.
## Capabilities
### New Capabilities
- `host-agent-local-console`: embedded local-only web UI for the Host Agent covering status monitoring, local device CRUD, local account password change, and bounded recent-assignment/heartbeat history, authenticated against the existing local account and disabled/loopback-bound by default.
### Modified Capabilities
(none — `host-agent-protocol` covers the outbound cloud protocol and is unaffected; this change only adds a local-only inbound surface)
## Impact
- Affected code: `apps/device-host-agent/host_agent/` (new `web` module/package, `app.py` wiring, `config.py` new fields), `apps/device-host-agent/pyproject.toml` (new HTTP server dependency), `storage/device_config.py` (consumed for device CRUD, no schema break expected), new local history storage (new SQLite table or file, scoped to the Host Agent).
- Not affected: `cloud.*`, `apps/cloud-api`, `cloud-console/`, `console/`, `host-agent-protocol` outbound behavior, `openspec/changes/edge-host-self-enrollment` (its CLI-only local-account bootstrap requirement is unchanged and remains the only way to create the *first* account; this change only adds a way to change the password afterward through the web UI).
- Operational impact: a new local listening port on edge devices when explicitly enabled; default-off and loopback-only by default keep the default deployment posture unchanged.
@@ -0,0 +1,118 @@
## ADDED Requirements
### Requirement: The local console is disabled by default and loopback-bound when enabled
The Host Agent SHALL NOT start any local web console listener unless explicitly enabled by configuration, and SHALL bind that listener to a loopback address unless a separate, explicit configuration setting authorizes a non-loopback bind address.
#### Scenario: Default configuration starts no console
- **WHEN** the Host Agent starts with no console-related configuration set
- **THEN** no local web console listener is started and existing heartbeat/claim behavior is unaffected
#### Scenario: Console enabled with default bind
- **WHEN** the console is enabled without a non-loopback opt-in
- **THEN** the console listener binds only to a loopback address
#### Scenario: Non-loopback bind requested without opt-in
- **WHEN** the console is configured to bind a non-loopback address without the separate non-loopback opt-in setting
- **THEN** the Host Agent fails configuration loading with a clear error and does not start
#### Scenario: Non-loopback bind requested with explicit opt-in
- **WHEN** the console is configured to bind a non-loopback address and the non-loopback opt-in setting is also set
- **THEN** the console listener binds to the configured address
### Requirement: Console authentication reuses the existing local account
The local web console SHALL authenticate operators against the same local account credential used by the Host Agent's `setup` command, and SHALL NOT introduce a separate credential store.
#### Scenario: No local account exists
- **WHEN** the console is enabled and no local account file is present
- **THEN** the console's login page reports that no account exists and directs the operator to run the setup command, without accepting any login attempt
#### Scenario: Valid login
- **WHEN** an operator submits the correct local account username and password to the console
- **THEN** the console establishes an authenticated session and grants access to console pages
#### Scenario: Invalid login
- **WHEN** an operator submits an incorrect password to the console
- **THEN** the console rejects the attempt without revealing whether the username or password was wrong and does not establish a session
#### Scenario: Unauthenticated access to a console page
- **WHEN** a request without a valid session reaches any console page other than the login page
- **THEN** the console redirects the request to the login page without exposing any status or device data
### Requirement: Console sessions are cookie-based, session-scoped, and CSRF-protected
The console SHALL issue an opaque session token on successful login, SHALL require a valid, unexpired session for all non-login requests, and SHALL require a session-bound CSRF token on every state-changing request.
#### Scenario: Session expires
- **WHEN** a console session has been idle longer than the configured session timeout
- **THEN** subsequent requests using that session are treated as unauthenticated and redirected to login
#### Scenario: Mutating request without CSRF token
- **WHEN** an authenticated session submits a device change, password change, or logout request without a valid CSRF token
- **THEN** the console rejects the request and makes no change
#### Scenario: Process restart invalidates sessions
- **WHEN** the Host Agent process restarts while the console is enabled
- **THEN** previously issued session tokens are no longer accepted and operators must log in again
### Requirement: Console displays current heartbeat, enrollment, device, and assignment status
The console SHALL present, on an authenticated status page, the most recent heartbeat outcome, the current enrollment/identity state, the list of locally registered devices with their status, the in-progress assignment (if any) and its execution status, and the effective control-plane configuration with credential values never rendered.
#### Scenario: Status page reflects current state
- **WHEN** an operator loads the authenticated status page
- **THEN** it shows the last heartbeat time and outcome, enrollment/host identity state, each registered device and its status, and whether an assignment is currently executing
#### Scenario: Credentials are never rendered
- **WHEN** the status page renders the effective control-plane configuration
- **THEN** the host token, local account password, and any other credential value are omitted or masked, never shown in full
#### Scenario: Status page refreshes without a full reload
- **WHEN** an operator keeps the status page open
- **THEN** the page periodically fetches updated status fragments and updates the displayed heartbeat/assignment state without a full page navigation
### Requirement: Console supports adding, editing, and removing local devices
The console SHALL allow an authenticated operator to add, edit, and remove locally registered device entries, and each change SHALL take effect on the running Host Agent immediately, without requiring a process restart.
#### Scenario: Add a device
- **WHEN** an operator submits a new device's driver type and connection info through the console
- **THEN** the device is persisted to local device configuration and becomes immediately available to the running Host Agent, including cloud enrollment when the Host is enrollment-managed
#### Scenario: Edit a device
- **WHEN** an operator updates an existing device's connection info through the console
- **THEN** the persisted configuration and the running Host Agent's device registration both reflect the update without a restart
#### Scenario: Remove a device
- **WHEN** an operator removes a device through the console
- **THEN** the device is deleted from local device configuration and unregistered from the running Host Agent immediately
### Requirement: Console supports changing the local account password
The console SHALL allow an authenticated operator to change the local account password after re-confirming the current password, and SHALL NOT allow creating the first local account through the web console.
#### Scenario: Successful password change
- **WHEN** an operator submits the correct current password along with a new password and confirmation
- **THEN** the local account credential is updated and future console logins require the new password
#### Scenario: Incorrect current password
- **WHEN** an operator submits an incorrect current password while attempting to change it
- **THEN** the console rejects the change and the existing credential remains valid
#### Scenario: No console path to create the first account
- **WHEN** no local account exists
- **THEN** the console offers no form to create one, and only the Host Agent's setup command can create it
### Requirement: Console shows a bounded local history of recent assignments and heartbeats
The Host Agent SHALL retain a bounded, local-only history of recent assignment outcomes and heartbeat syncs, and the console SHALL display this history to an authenticated operator, independent of whether the Cloud Console is reachable.
#### Scenario: Assignment outcome recorded
- **WHEN** the Host Agent reports a terminal assignment result to the control plane
- **THEN** a corresponding entry recording the outcome is added to the local history and becomes visible on the console's history page
#### Scenario: Heartbeat recorded
- **WHEN** the Host Agent completes a heartbeat sync
- **THEN** a corresponding entry is added to the local history
#### Scenario: History is bounded
- **WHEN** the number of recorded history entries exceeds the configured retention limit
- **THEN** the oldest entries are pruned so the stored history does not grow unbounded
#### Scenario: History available without console enabled
- **WHEN** the console is disabled
- **THEN** the Host Agent does not record local history and incurs no related overhead
@@ -0,0 +1,55 @@
## 1. Config and dependencies
- [ ] 1.1 Add `fastapi` and `uvicorn[standard]` as explicit direct dependencies in `apps/device-host-agent/pyproject.toml` (versions matching those already pinned in `uv.lock`)
- [ ] 1.2 Add `console_enabled`, `console_bind_host`, `console_port`, `console_allow_non_loopback`, `console_session_ttl_seconds`, `console_history_limit` fields to `HostAgentConfig` in `host_agent/config.py`, plus matching `HOST_AGENT_CONSOLE_*` env vars in `load_host_agent_config`, reusing the existing `_positive_float`/`_positive_int` validators
- [ ] 1.3 Add validation that raises `HostAgentConfigurationError` when `console_bind_host` is non-loopback and `console_allow_non_loopback` is not set
- [ ] 1.4 Add unit tests in `apps/device-host-agent/tests/test_config.py` for defaults, env var parsing, and the non-loopback-without-opt-in rejection
## 2. Shared device-registration helper
- [ ] 2.1 Extract the device-add sequence (`DeviceConfigStore.add`/`set_cloud_device_id` + optional `enrollment_client.enroll_device` + `manager.register_device`) currently inlined in `host_agent/app.py::_configured_device_manager` into a small shared function usable by both startup and the console
- [ ] 2.2 Add a matching shared function for device removal (`DeviceConfigStore.remove` + `manager.unregister_device`)
- [ ] 2.3 Update `_configured_device_manager` to use the extracted add helper; confirm existing `test_app.py` startup tests still pass unchanged
## 3. Local history store
- [ ] 3.1 Add a new `host_agent/history.py` module with a `ConsoleHistoryStore` backed by a small SQLite file (e.g. `tasks/host_console_history.sqlite3`), supporting `record_assignment(...)`, `record_heartbeat(...)`, and `list_recent(limit)`, pruning beyond `console_history_limit` on write
- [ ] 3.2 Add an optional recorder hook to `AssignmentProcessor.process` (host_agent/processor.py) invoked after a terminal result is reported, no-op when no recorder is configured
- [ ] 3.3 Add an optional recorder hook to `HeartbeatSynchronizer.sync_once` (host_agent/heartbeat.py) invoked after each successful sync, no-op when no recorder is configured
- [ ] 3.4 Unit tests for `ConsoleHistoryStore` (write, prune-on-overflow, ordering) and for the processor/heartbeat recorder hooks firing with the expected data and being skipped when absent
## 4. Session and authentication
- [ ] 4.1 Add `host_agent/web/auth.py` with an in-memory session store (opaque token → session state with expiry), login verification against `LocalAccountStore`, and CSRF token issuance/validation bound to the session
- [ ] 4.2 Implement session cookie handling (`HttpOnly`, `SameSite=Strict`, `Secure` when bind host is non-loopback) and sliding expiry per `console_session_ttl_seconds`
- [ ] 4.3 Implement an auth dependency/middleware that redirects unauthenticated requests to `/login` and rejects mutating requests lacking a valid CSRF token
- [ ] 4.4 Unit tests: successful login, wrong password, no-account-yet state, session expiry, CSRF rejection on a mutating route, redirect-to-login for an unauthenticated GET
## 5. Console pages and routes
- [ ] 5.1 Add `host_agent/web/app.py` building a FastAPI sub-application with hand-written HTML responses (f-string templates + a shared `escape()` helper for every interpolated value) for: `/login`, `/` (status dashboard), `/devices`, `/account`, `/history`
- [ ] 5.2 Implement `/login` (GET form, POST verify+establish session) per spec scenarios, including the "no local account exists" state
- [ ] 5.3 Implement the status dashboard: last heartbeat outcome/time, enrollment/identity state, device list with status, current assignment/execution state, sanitized effective config (no token/password rendered); add a small JSON status-fragment endpoint polled via inline `fetch()` for refresh without full reload
- [ ] 5.4 Implement `/devices`: list, add, edit, remove forms wired to the section-2 shared helpers, taking effect on the live `DeviceManager` immediately
- [ ] 5.5 Implement `/account`: change-password form requiring current password re-entry, calling `LocalAccountStore.create` (or an equivalent update path) only after verifying the current credential
- [ ] 5.6 Implement `/history`: read-only table of recent assignment/heartbeat entries from `ConsoleHistoryStore`
- [ ] 5.7 Implement `/logout` (CSRF-protected POST) invalidating the session
## 6. Lifecycle wiring
- [ ] 6.1 In `host_agent/app.py::create_application`, construct the console app, session store, and `ConsoleHistoryStore` only when `config.console_enabled`, and wire the recorder hooks from section 3 into the constructed `AssignmentProcessor`/`HeartbeatSynchronizer`
- [ ] 6.2 In `HostAgentApplication.run_async`, start a `uvicorn.Server` task (bound to `console_bind_host`/`console_port`, `install_signal_handlers=False`) alongside the heartbeat task when the console is configured, and stop it in the existing `finally` shutdown sequence
- [ ] 6.3 Integration test exercising the full lifecycle with the console enabled: process starts, console responds on the configured loopback port, process shuts down cleanly and stops the console server
- [ ] 6.4 Integration test confirming that with the console left at its default (disabled), no listening socket is opened and existing `test_app.py`/`test_e2e.py` behavior is unaffected
## 7. Documentation
- [ ] 7.1 Document the new `HOST_AGENT_CONSOLE_*` environment variables, default-off/loopback-only posture, and the SSH port-forward recommendation for remote access in `docs/CLOUD_DEPLOYMENT.md`
- [ ] 7.2 Add a short section to `docs/MACOS_IPHONE_SETUP.md` describing how to enable the console on an edge machine and what it shows
## 8. Validation
- [ ] 8.1 Run `uv run --package device-host-agent pytest` (full package suite) and the root non-integration suite; confirm no regressions
- [ ] 8.2 Run Ruff check/format and `python -m compileall` over the changed files
- [ ] 8.3 Manually verify in a browser: login, status dashboard auto-refresh, add/edit/remove a device, change password, view history, logout, and confirm the console refuses to bind non-loopback without the opt-in flag
- [ ] 8.4 Run `openspec validate host-agent-local-console --strict` and confirm it passes
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-13
@@ -0,0 +1,118 @@
## Context
The Cloud API currently chains configured bearer credentials, browser user
sessions, and repository-backed Host credentials. Three JSON environment
variables populate the first provider and a second provider protects first Host
enrollment. The deployment image already contains the Console bundle, but
Compose separately supplies its fixed location. The Console also retains a
bearer-token compatibility screen and an administrator-only Users view.
The intended deployment has one trusted Cloud endpoint. A human operator signs
in with a Cloud account created or recovered through `device-cloud-admin`; a
new Host Agent registers itself once, generates its own high-entropy Host
secret, and persists the returned Host identity locally. Subsequent Host
requests continue to authenticate against the stored digest.
## Goals / Non-Goals
**Goals:**
- Start Cloud production without any static credential JSON or startup
requirement for one.
- Authorize browser operations by the existing user session and preserve its
role, CSRF, expiry, audit, and interactive CLI controls.
- Accept direct first Host registration, then strictly bind all later Host
operations to the registered Host identity and persisted secret.
- Remove obsolete Console and Compose configuration paths while keeping the
Jenkins-built Console available at `/console/`.
**Non-Goals:**
- No personal-access-token, service-account, or replacement SDK credential
scheme in this change.
- No user-directory view in the Console. Account provisioning and recovery
remain administration-CLI operations.
- No Host enrollment approval, rate limiting, or multi-tenant trust policy.
Network reachability to the trusted Cloud endpoint remains the boundary
explicitly chosen for this deployment.
- No change to durable task, lease, device, or user database schema.
## Decisions
### D1. Remove configured bearer providers instead of leaving empty compatibility configuration
`CloudControlConfig` will no longer parse the three credential JSON variables
or require credentials in production. The Cloud application will compose public
authorization from `UserSessionAuthProvider` only, while Host operational
authorization remains `RepositoryHostAuthProvider`. An empty production user
table is allowed so an administrator can execute the interactive bootstrap CLI
after migrations complete.
Leaving the variables optional was rejected because it preserves two
authentication modes and makes operators believe a deployed secret is needed.
Adding user-issued API tokens was rejected because the requested outcome is no
manual token configuration and it introduces a separate credential lifecycle.
### D2. Direct enrollment is a single unauthenticated bootstrap operation
The enrollment endpoint accepts a fresh Host's generated instance identifier
and candidate secret without an `Authorization` header. It stores only the
candidate secret digest and returns a generated `host_id`; the Host persists
that result atomically. The configured enrollment-token provider and static
Host configuration are removed. Heartbeat, device enrollment, claim, renewal,
and result endpoints remain Host-bound and require the persisted bearer secret.
An opt-in enrollment flag was rejected because the target deployment always
uses direct trust and the flag is another operational switch that can silently
block first-run setup. Keeping token enrollment as a fallback was rejected for
the same reason as D1.
### D3. Keep user administration off the Console, not out of the control plane
The Console retains login, logout, current-account display, and password
change. It removes bearer-token controls, token storage, and all Users routes,
navigation, client calls, and components. The authenticated user administration
API and `device-cloud-admin` CLI remain available for controlled provisioning
and recovery; the CLI is the documented initial-admin path.
Removing the administration API entirely was rejected because it would make
recovery tooling less complete and is unrelated to eliminating deployment
tokens.
### D4. Treat packaged static assets as an image contract
The Dockerfile sets `CLOUD_CONSOLE_STATIC_DIR=/app/console-static` after copying
the built SPA. Compose does not repeat this invariant. Application code keeps
the variable optional for source-based local development, where no bundled
directory exists.
## Risks / Trade-offs
- **[Risk] Any network caller can create a Host identity.** → The accepted
mitigation is reverse-proxy/firewall control of the trusted Cloud endpoint;
every operation after enrollment still requires the Host-generated secret.
- **[Risk] Existing integrations using static bearer tokens stop working.** →
This is intentional; create Cloud user accounts before deployment and migrate
human workflows to session login.
- **[Risk] An upgrade before an administrator exists leaves no human API
access.** → Run the documented interactive `device-cloud-admin users create`
command immediately after migration and before exposing the Console.
- **[Risk] Existing static or token-enrolled Hosts cannot rely on removed
configuration after upgrade.** → Preserve their already persisted dynamic
identities where present; otherwise perform a fresh direct enrollment.
## Migration Plan
1. Build and deploy the image with the schema migration already included.
2. Start the Cloud API using only database, production, and network settings.
3. Run `device-cloud-admin users create` interactively to bootstrap an
administrator, then verify Console login over HTTPS.
4. Start each new Host Agent with its persistent identity path; it directly
enrolls once and then uses its durable secret for future starts.
5. Remove the legacy credential JSON values from the deployment secret store.
Rollback requires restoring an earlier image and its matching static
credentials before restarting any Host that lacks a durable enrolled identity.
## Open Questions
- None. The user explicitly accepts direct Host enrollment behind the network
perimeter and does not require a Console user-directory surface.
@@ -0,0 +1,49 @@
## Why
The Cloud deployment currently requires operators to hand-maintain public API,
Host, and enrollment bearer tokens in Compose environment variables. Cloud
operator accounts now provide the human authentication boundary, while a Host
Agent can establish its own durable identity by registering directly with the
trusted control plane. Retaining both models makes deployment error-prone and
leaves secrets in configuration without serving the intended workflow.
## What Changes
- **BREAKING** Remove configured public, static Host, and enrollment bearer
credentials from the Cloud Control Plane configuration and deployment
examples.
- Authorize human Cloud API access exclusively through persistent Cloud user
sessions; remove the Console's bearer-token path and user-directory UI.
Interactive `device-cloud-admin` commands remain the account provisioning and
recovery surface.
- Enable a fresh Host Agent to register directly with the trusted Cloud API
without a pre-shared enrollment token, then use its persisted, cloud-bound
secret for all later Host operations.
- Bake the packaged Console static directory into the production image rather
than repeating it in Compose, and remove Compose entries whose values merely
duplicate application defaults or have no runtime effect.
- Update deployment documentation and tests to describe and enforce the
tokenless production flow.
## Capabilities
### New Capabilities
- `cloud-operator-authentication`: Cloud operator sign-in, CLI account
provisioning, and a token-free Console experience.
- `credentialless-host-bootstrap`: Direct Host registration and durable
post-registration identity without static deployment credentials.
### Modified Capabilities
- `platform-sdk`: Replace configured bearer credentials as the production
authorization prerequisite with Cloud user-session authorization.
- `host-agent-protocol`: Add unauthenticated first registration while retaining
host-bound authentication for every subsequent Host operation.
## Impact
Affected areas include the Cloud control configuration and authentication
composition, public and internal Cloud routers, Host Agent configuration and
enrollment client, Console UI, Docker/Compose deployment assets, tests, and
`docs/CLOUD_DEPLOYMENT.md`. Existing deployments using the removed static token
variables must create an administrator and re-enroll Hosts through the new
flow before upgrading.
@@ -0,0 +1,43 @@
## ADDED Requirements
### Requirement: Cloud operator sessions are the only human API authentication path
The system SHALL authorize human Cloud API operations through persistent Cloud
user sessions and SHALL not require configured static bearer credentials for
production startup or normal Console operation.
#### Scenario: Production starts without credential JSON
- **WHEN** the Cloud API starts in production with a current database schema
and no configured public, Host, or enrollment credential JSON
- **THEN** the application starts, exposes health and authentication routes,
and rejects unauthenticated protected API requests
#### Scenario: Signed-in operator uses a protected API route
- **WHEN** an enabled Cloud user has a valid session and calls an operation
allowed by the user's role scopes
- **THEN** the operation is authorized without an Authorization bearer header
### Requirement: The Console contains no token or user-directory management surface
The Console SHALL offer login, logout, current-account display, and password
change, and SHALL not render bearer-token controls, token persistence, Users
navigation, or user-management forms.
#### Scenario: Unauthenticated operator opens the Console
- **WHEN** no valid user session exists
- **THEN** the Console presents the username/password login flow without an API
token alternative
#### Scenario: Administrator opens the Console
- **WHEN** an administrator signs in
- **THEN** the Console presents normal authorized operational views but no
user-directory navigation or account lifecycle form
### Requirement: Account provisioning and recovery remain interactive administration operations
The system SHALL retain interactive, non-echoed administration CLI commands to
create, reset, enable, and revoke Cloud user accounts without accepting
passwords through Compose configuration or command-line arguments.
#### Scenario: Initial administrator is created after deployment
- **WHEN** an operator runs `device-cloud-admin users create` against the
migrated Cloud database and completes the password prompts
- **THEN** an enabled administrator account is created without a deployment
token or plaintext password in process arguments
@@ -0,0 +1,43 @@
## ADDED Requirements
### Requirement: Fresh Hosts register directly with the trusted Cloud API
The Cloud API SHALL accept a first Host registration without a configured
enrollment token or Authorization header, assign a durable `host_id`, and store
only the digest of the Host-generated high-entropy secret.
#### Scenario: Fresh Host registers without static configuration
- **WHEN** a Host Agent with no cached identity sends a valid instance
identifier and generated Host secret to the enrollment endpoint
- **THEN** the Cloud API creates or returns the durable Host identity and does
not require a pre-shared deployment credential
#### Scenario: Registration is retried by the same Host
- **WHEN** the same Host retries registration with its original instance
identifier and candidate secret after losing the response
- **THEN** the Cloud API returns the existing Host identity without creating a
duplicate Host row
### Requirement: Registered Hosts authenticate all later operational requests
The system SHALL require the persisted Host secret for device enrollment,
heartbeat, claim, renewal, and result operations after first registration, and
SHALL bind each accepted request to its registered `host_id`.
#### Scenario: Registered Host sends a heartbeat
- **WHEN** a Host presents its persisted secret for its own Host identity
- **THEN** the Cloud API accepts the heartbeat subject to normal validation
#### Scenario: Caller attempts a Host operation without its secret
- **WHEN** a caller accesses any post-registration Host operation without a
valid secret bound to the path Host identity
- **THEN** the Cloud API rejects the operation without changing Host state
### Requirement: Host Agent configuration contains no static Cloud credential
The Host Agent SHALL use its cached identity when present and otherwise perform
direct registration, without `HOST_AGENT_HOST_ID`, `HOST_AGENT_TOKEN`, or
`HOST_AGENT_ENROLLMENT_TOKEN` configuration.
#### Scenario: Fresh Host starts with only its local state path
- **WHEN** a Host Agent starts with no cached Cloud identity and no static Host
credential environment values
- **THEN** it generates and persists an identity through direct registration
before starting heartbeat or assignment polling
@@ -0,0 +1,39 @@
## ADDED Requirements
### Requirement: Host identity can be established by direct registration
The internal Host Agent API SHALL accept a fresh Host registration without a
pre-shared deployment credential, SHALL assign a durable Host identifier, and
SHALL store only the digest of the Host-generated secret for subsequent
host-scoped authentication.
#### Scenario: Fresh Host establishes an identity
- **WHEN** a fresh Host Agent submits a valid registration request containing
its instance identifier and generated secret
- **THEN** the control plane returns a durable Host identifier and stores only
the secret digest bound to that Host
#### Scenario: Registration request is retried
- **WHEN** the same Host Agent repeats registration with its original instance
identifier and secret after a lost response
- **THEN** the control plane returns the existing Host identifier without
creating a second Host identity
## MODIFIED Requirements
### Requirement: Host identity is authenticated and bound to one host id
After direct registration, the internal Host Agent API SHALL require a
host-scoped bearer principal for all Host operational requests and SHALL reject
any request that attempts to act for a `host_id` different from the
authenticated principal's bound host. The initial registration endpoint is the
only exception and establishes that bound principal.
#### Scenario: Host authenticates as itself after registration
- **WHEN** a Host Agent presents the persisted generated secret bound to its
requested `host_id`
- **THEN** the internal API authorizes permitted heartbeat, claim, renewal, and
result operations
#### Scenario: Host attempts to impersonate another host
- **WHEN** valid credentials bound to host A are used on a request for host B
- **THEN** the internal API rejects the request without reading or modifying
host B's state
@@ -0,0 +1,36 @@
## MODIFIED Requirements
### Requirement: Pluggable authentication hook with a safe default
The system SHALL evaluate every platform SDK route through a configurable
scope-aware `AuthProvider` hook. The deployable Cloud Control Plane SHALL
reject anonymous access outside an explicit insecure-development override, but
production startup SHALL not require configured static bearer credentials.
#### Scenario: Production starts without configured bearer credentials
- **WHEN** the Cloud Control Plane is configured as production with a usable
database and no static bearer credential configuration
- **THEN** startup succeeds and protected platform routes reject unauthenticated
requests
#### Scenario: Explicit local anonymous override
- **WHEN** a non-production operator explicitly enables the insecure
anonymous-development override
- **THEN** platform routes may use an anonymous principal and the application
records that insecure mode is active
#### Scenario: Custom AuthProvider is honored
- **WHEN** a caller configures a custom `AuthProvider` that rejects a request
or omits its required scope
- **THEN** the platform SDK route returns an authentication or authorization
error without executing its handler operation
## REMOVED Requirements
### Requirement: Python SDK supports authenticated requests
**Reason**: The deployment no longer provisions or accepts static bearer
credentials for public platform access; human operations use Cloud user
sessions.
**Migration**: Replace bearer-token SDK workflows with authenticated Console
user-session workflows. A non-human service credential model is outside this
change and must be designed separately before reintroducing SDK automation.
@@ -0,0 +1,30 @@
## 1. Cloud authentication configuration
- [x] 1.1 Remove configured public and static-Host bearer credential parsing and production credential validation from `CloudControlConfig`, while retaining the optional trusted-proxy setting used by login throttling.
- [x] 1.2 Compose public authorization from Cloud user sessions and dynamic repository-backed Host credentials only; remove configured enrollment-token authentication.
- [x] 1.3 Update Cloud configuration and application tests to prove production starts without JSON credentials, protected API routes reject anonymous requests, and user sessions retain scoped authorization.
## 2. Direct Host bootstrap
- [x] 2.1 Remove static Host and enrollment-token settings from Host Agent configuration while retaining persistent identity-file validation and control-plane URL overrides.
- [x] 2.2 Change the Host enrollment client and identity resolution to perform unauthenticated first registration and persist the returned Host identity and generated secret.
- [x] 2.3 Change the Cloud enrollment route to accept direct registration, preserve idempotency for the same instance, and require the persisted Host secret for all later Host operations.
- [x] 2.4 Add Cloud and Host Agent tests for direct registration, idempotent retry, missing/invalid post-registration Host credentials, and absence of static credential settings.
## 3. Console token and user-directory removal
- [x] 3.1 Remove bearer-token compatibility state, controls, client behavior, and related tests from the Cloud Console.
- [x] 3.2 Remove Console Users navigation, views, client calls, and tests while retaining login, logout, current-user, and password-change behavior.
- [x] 3.3 Add or update Console tests covering session-only login and the absence of token and user-directory UI paths.
## 4. Deployment contract and documentation
- [x] 4.1 Bake the Console static directory into the Docker image environment and remove it, credential JSON, static Host credentials, and redundant defaults from Compose and `.env.example`.
- [x] 4.2 Update deployment documentation for user-account bootstrap, direct Host registration, required runtime configuration, rollback, and the actual deploy Compose topology.
- [x] 4.3 Update deployment-configuration tests to enforce the reduced environment contract and image-provided Console path.
## 5. Verification and change validation
- [x] 5.1 Run focused Cloud API, Cloud platform, Host Agent, deployment, and Console test suites; fix failures caused by the removed credential paths.
- [ ] 5.2 Render `compose.deploy.yaml` with representative non-secret settings and verify the Cloud API command, health check, and environment contract.
- [ ] 5.3 Run the full non-integration workspace suite, Console build, and strict OpenSpec validation; record any environment-gated checks that cannot run locally.
+9 -76
View File
@@ -84,29 +84,6 @@ class _StoredBearerCredential:
token_digest: bytes = field(repr=False)
@dataclass(frozen=True)
class EnrollmentCredential:
principal_id: str
token: str = field(repr=False)
def __post_init__(self) -> None:
if not self.principal_id.strip():
raise ValueError("enrollment credential principal_id must not be empty")
if not self.token:
raise ValueError("enrollment credential token must not be empty")
@dataclass(frozen=True)
class EnrollmentPrincipal:
id: str
token_digest: str | None = field(default=None, repr=False)
@runtime_checkable
class EnrollmentAuthProvider(Protocol):
def authenticate(self, request: object) -> EnrollmentPrincipal | None: ...
class ConfiguredBearerAuthProvider:
"""Authenticate configured bearer tokens without exposing credential text."""
@@ -136,50 +113,6 @@ class ConfiguredBearerAuthProvider:
return matched_principal
class ConfiguredEnrollmentTokenProvider:
def __init__(self, credentials: Iterable[EnrollmentCredential]) -> None:
self._credentials = tuple(
EnrollmentPrincipal(
id=credential.principal_id,
token_digest=digest_token(credential.token),
)
for credential in credentials
)
def authenticate(self, request: object) -> EnrollmentPrincipal | None:
candidate = bearer_token_digest(request)
if candidate is None:
return None
candidate_bytes = bytes.fromhex(candidate)
matched: EnrollmentPrincipal | None = None
for credential in self._credentials:
if compare_digest(
candidate_bytes,
bytes.fromhex(credential.token_digest),
):
matched = credential
return matched
class SelfServiceEnrollmentAuthProvider:
"""Unconditionally authorizes Host enrollment with no pre-issued token."""
def authenticate(self, request: object) -> EnrollmentPrincipal | None:
return EnrollmentPrincipal(id="self-service", token_digest=None)
class ChainedEnrollmentAuthProvider:
def __init__(self, providers: Iterable[EnrollmentAuthProvider]) -> None:
self.providers = tuple(providers)
def authenticate(self, request: object) -> EnrollmentPrincipal | None:
for provider in self.providers:
principal = provider.authenticate(request)
if principal is not None:
return principal
return None
class RepositoryHostAuthProvider:
def __init__(self, repository: CloudRepository) -> None:
self.repository = repository
@@ -230,17 +163,17 @@ class ChainedAuthProvider:
return None
def create_auth_provider(
credentials: Iterable[BearerCredential],
*,
allow_insecure_anonymous: bool,
) -> AuthProvider:
configured = list(credentials)
if configured:
return ConfiguredBearerAuthProvider(configured)
class RejectingAuthProvider:
"""Safe default for deployments that do not configure static API tokens."""
def authenticate(self, request: object) -> Principal | None:
return None
def create_auth_provider(*, allow_insecure_anonymous: bool) -> AuthProvider:
if allow_insecure_anonymous:
return NullAuthProvider()
return ConfiguredBearerAuthProvider([])
return RejectingAuthProvider()
def _extract_bearer_token(request: object) -> str | None:
@@ -1,13 +1,10 @@
from __future__ import annotations
import json
import os
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Literal
from cloud.auth import BearerCredential, EnrollmentCredential
EnvironmentName = Literal["local", "test", "production"]
SUPPORTED_DATABASE_PREFIXES = (
@@ -30,9 +27,6 @@ class CloudControlConfig:
lease_duration_seconds: float = 60.0
max_task_attempts: int = 3
allow_insecure_anonymous: bool = False
credentials: tuple[BearerCredential, ...] = ()
enrollment_credentials: tuple[EnrollmentCredential, ...] = ()
self_service_enrollment_enabled: bool = False
cors_allowed_origins: tuple[str, ...] = ()
console_static_dir: str | None = None
user_session_idle_seconds: int = 28_800
@@ -90,20 +84,6 @@ def load_control_config(
values.get("CLOUD_ALLOW_INSECURE_ANONYMOUS"),
default=False,
),
credentials=(
*_parse_credentials(values.get("CLOUD_PUBLIC_CREDENTIALS_JSON")),
*_parse_credentials(
values.get("CLOUD_HOST_CREDENTIALS_JSON"),
require_host_id=True,
),
),
enrollment_credentials=_parse_enrollment_credentials(
values.get("CLOUD_ENROLLMENT_TOKENS_JSON")
),
self_service_enrollment_enabled=_parse_bool(
values.get("CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED"),
default=False,
),
cors_allowed_origins=_parse_cors_origins(
values.get("CLOUD_CONSOLE_CORS_ORIGINS")
),
@@ -145,10 +125,6 @@ def validate_control_config(config: CloudControlConfig) -> None:
raise CloudConfigurationError(
"anonymous access cannot be enabled in production"
)
if config.environment == "production" and not config.credentials:
raise CloudConfigurationError(
"production requires at least one configured bearer credential"
)
if config.user_session_absolute_seconds < config.user_session_idle_seconds:
raise CloudConfigurationError(
"CLOUD_USER_SESSION_ABSOLUTE_SECONDS must be at least the idle TTL"
@@ -159,76 +135,6 @@ def validate_control_config(config: CloudControlConfig) -> None:
)
def _parse_credentials(
raw_value: str | None,
*,
require_host_id: bool = False,
) -> tuple[BearerCredential, ...]:
if raw_value is None or not raw_value.strip():
return ()
try:
payload = json.loads(raw_value)
if not isinstance(payload, list):
raise TypeError
credentials: list[BearerCredential] = []
for item in payload:
if not isinstance(item, dict):
raise TypeError
principal_id = item.get("principal_id")
token = item.get("token")
scopes = item.get("scopes", [])
host_id = item.get("host_id")
if (
not isinstance(principal_id, str)
or not isinstance(token, str)
or not isinstance(scopes, list)
or not all(isinstance(scope, str) for scope in scopes)
or (host_id is not None and not isinstance(host_id, str))
or (require_host_id and not isinstance(host_id, str))
):
raise TypeError
credentials.append(
BearerCredential(
principal_id=principal_id,
token=token,
scopes=frozenset(scopes),
host_id=host_id,
)
)
return tuple(credentials)
except (TypeError, ValueError, json.JSONDecodeError) as exc:
raise CloudConfigurationError(
"configured bearer credentials are invalid"
) from exc
def _parse_enrollment_credentials(
raw_value: str | None,
) -> tuple[EnrollmentCredential, ...]:
if raw_value is None or not raw_value.strip():
return ()
try:
payload = json.loads(raw_value)
if not isinstance(payload, list):
raise TypeError
credentials: list[EnrollmentCredential] = []
for item in payload:
if not isinstance(item, dict):
raise TypeError
principal_id = item.get("principal_id")
token = item.get("token")
if not isinstance(principal_id, str) or not isinstance(token, str):
raise TypeError
credentials.append(
EnrollmentCredential(principal_id=principal_id, token=token)
)
return tuple(credentials)
except (TypeError, ValueError, json.JSONDecodeError) as exc:
raise CloudConfigurationError(
"configured enrollment credentials are invalid"
) from exc
def _positive_float(
values: Mapping[str, str],
name: str,
@@ -12,8 +12,6 @@ from fastapi.responses import JSONResponse
from cloud.auth import (
AuthProvider,
ConfiguredEnrollmentTokenProvider,
EnrollmentAuthProvider,
HostAuthorizationError,
digest_token,
)
@@ -35,7 +33,6 @@ from cloud.internal_api.models import (
)
from cloud.repository import (
DeviceEnrollmentConflictError,
EnrollmentTokenConflictError,
HostEnrollmentConflictError,
)
from core.models import Device, utc_now
@@ -48,7 +45,6 @@ def create_internal_router(
*,
pool: DevicePool,
auth_provider: AuthProvider,
enrollment_auth_provider: EnrollmentAuthProvider | None = None,
version_prefix: str = "/internal/v1",
claim_poll_interval_seconds: float = 0.1,
lease_duration_seconds: float = 60.0,
@@ -59,8 +55,6 @@ def create_internal_router(
if lease_duration_seconds <= 0:
raise ValueError("lease_duration_seconds must be greater than zero")
router = APIRouter(prefix=version_prefix, tags=["host-agent"])
enrollment_auth = enrollment_auth_provider or ConfiguredEnrollmentTokenProvider(())
def authorize_host(request: Request, host_id: str) -> None:
principal = auth_provider.authenticate(request)
if principal is None:
@@ -84,25 +78,17 @@ def create_internal_router(
)
def enroll_host(
payload: HostEnrollmentRequest,
request: Request,
) -> HostEnrollmentResponse:
enrollment_principal = enrollment_auth.authenticate(request)
if enrollment_principal is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="unauthorized",
headers={"WWW-Authenticate": "Bearer"},
)
try:
enrollment = pool.store.enroll_host(
host_id=f"host-{uuid4().hex}",
agent_instance_id=payload.agent_instance_id,
credential_digest=digest_token(payload.host_token),
enrollment_token_digest=enrollment_principal.token_digest,
enrollment_token_digest=None,
display_name=payload.display_name,
enrolled_at=utc_now(),
)
except (EnrollmentTokenConflictError, HostEnrollmentConflictError) as exc:
except HostEnrollmentConflictError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
-86
View File
@@ -7,15 +7,11 @@ import pytest
from cloud.auth import (
BearerCredential,
ChainedAuthProvider,
ChainedEnrollmentAuthProvider,
ConfiguredBearerAuthProvider,
ConfiguredEnrollmentTokenProvider,
EnrollmentCredential,
HostIdentityMismatchError,
HostPrincipalRequiredError,
NullAuthProvider,
RepositoryHostAuthProvider,
SelfServiceEnrollmentAuthProvider,
digest_token,
)
@@ -171,24 +167,6 @@ def test_authentication_failure_does_not_log_bearer_secret(caplog) -> None:
assert "valid-secret" not in caplog.text
def test_enrollment_token_provider_returns_digest_without_exposing_secret() -> None:
credential = EnrollmentCredential(
principal_id="installer-a",
token="one-time-enrollment-secret",
)
provider = ConfiguredEnrollmentTokenProvider([credential])
principal = provider.authenticate(
_Request(headers={"authorization": "Bearer one-time-enrollment-secret"})
)
assert principal is not None
assert principal.id == "installer-a"
assert principal.token_digest == digest_token("one-time-enrollment-secret")
assert "one-time-enrollment-secret" not in repr(credential)
assert "one-time-enrollment-secret" not in repr(provider.__dict__)
def test_repository_host_auth_and_chain_preserve_host_scope() -> None:
class Repository:
def authenticate_enrolled_host(self, credential_digest: str) -> str | None:
@@ -215,67 +193,3 @@ def test_repository_host_auth_and_chain_preserve_host_scope() -> None:
assert host_principal is not None
assert host_principal.host_id == "host-managed"
assert host_principal.scopes == frozenset()
def test_self_service_enabled_with_no_token_enrolls_via_self_service() -> None:
provider = ChainedEnrollmentAuthProvider(
[ConfiguredEnrollmentTokenProvider(()), SelfServiceEnrollmentAuthProvider()]
)
principal = provider.authenticate(_Request(headers={}))
assert principal is not None
assert principal.id == "self-service"
assert principal.token_digest is None
def test_self_service_disabled_with_no_token_is_unauthenticated() -> None:
provider = ChainedEnrollmentAuthProvider([ConfiguredEnrollmentTokenProvider(())])
assert provider.authenticate(_Request(headers={})) is None
def test_self_service_enabled_with_valid_configured_token_uses_token_bound_path() -> (
None
):
credential = EnrollmentCredential(
principal_id="installer-a",
token="one-time-enrollment-secret",
)
provider = ChainedEnrollmentAuthProvider(
[
ConfiguredEnrollmentTokenProvider([credential]),
SelfServiceEnrollmentAuthProvider(),
]
)
principal = provider.authenticate(
_Request(headers={"authorization": "Bearer one-time-enrollment-secret"})
)
assert principal is not None
assert principal.id == "installer-a"
assert principal.token_digest == digest_token("one-time-enrollment-secret")
def test_self_service_enabled_with_unknown_token_falls_through_to_self_service() -> (
None
):
credential = EnrollmentCredential(
principal_id="installer-a",
token="one-time-enrollment-secret",
)
provider = ChainedEnrollmentAuthProvider(
[
ConfiguredEnrollmentTokenProvider([credential]),
SelfServiceEnrollmentAuthProvider(),
]
)
principal = provider.authenticate(
_Request(headers={"authorization": "Bearer unknown-secret"})
)
assert principal is not None
assert principal.id == "self-service"
assert principal.token_digest is None
+16 -72
View File
@@ -2,11 +2,7 @@ from __future__ import annotations
import pytest
from cloud.auth import (
ConfiguredBearerAuthProvider,
NullAuthProvider,
create_auth_provider,
)
from cloud.auth import NullAuthProvider, RejectingAuthProvider, create_auth_provider
from cloud.control_config import (
CloudConfigurationError,
CloudControlConfig,
@@ -18,7 +14,7 @@ def test_load_control_config_uses_safe_local_defaults() -> None:
assert load_control_config({}) == CloudControlConfig()
def test_load_control_config_parses_deployment_values() -> None:
def test_production_configuration_needs_no_static_credential_json() -> None:
config = load_control_config(
{
"CLOUD_ENVIRONMENT": "production",
@@ -27,14 +23,6 @@ def test_load_control_config_parses_deployment_values() -> None:
"CLOUD_LEASE_REAPER_INTERVAL_SECONDS": "7",
"CLOUD_LEASE_DURATION_SECONDS": "90",
"CLOUD_MAX_TASK_ATTEMPTS": "5",
"CLOUD_PUBLIC_CREDENTIALS_JSON": (
'[{"principal_id":"sdk","token":"sdk-secret",'
'"scopes":["tasks:submit","tasks:read"]}]'
),
"CLOUD_HOST_CREDENTIALS_JSON": (
'[{"principal_id":"agent-a","token":"host-secret",'
'"host_id":"host-a","scopes":["host:agent"]}]'
),
}
)
@@ -42,23 +30,6 @@ def test_load_control_config_parses_deployment_values() -> None:
assert config.database_url.startswith("postgresql+psycopg://")
assert config.scheduler_interval_seconds == 2.5
assert config.max_task_attempts == 5
assert len(config.credentials) == 2
assert config.credentials[1].host_id == "host-a"
assert "sdk-secret" not in repr(config)
def test_load_control_config_parses_enrollment_credentials() -> None:
config = load_control_config(
{
"CLOUD_ENROLLMENT_TOKENS_JSON": (
'[{"principal_id":"installer-a","token":"one-time-enrollment-secret"}]'
)
}
)
assert len(config.enrollment_credentials) == 1
assert config.enrollment_credentials[0].principal_id == "installer-a"
assert "one-time-enrollment-secret" not in repr(config)
@pytest.mark.parametrize(
@@ -85,19 +56,6 @@ def test_load_control_config_rejects_unsafe_production_anonymous_mode() -> None:
"CLOUD_ENVIRONMENT": "production",
"CLOUD_DATABASE_URL": "postgresql://db/cloud",
"CLOUD_ALLOW_INSECURE_ANONYMOUS": "true",
"CLOUD_PUBLIC_CREDENTIALS_JSON": (
'[{"principal_id":"sdk","token":"secret","scopes":[]}]'
),
}
)
def test_load_control_config_rejects_missing_production_credentials() -> None:
with pytest.raises(CloudConfigurationError, match="credential"):
load_control_config(
{
"CLOUD_ENVIRONMENT": "production",
"CLOUD_DATABASE_URL": "postgresql://db/cloud",
}
)
@@ -139,43 +97,29 @@ def test_production_requires_secure_user_session_cookie() -> None:
{
"CLOUD_ENVIRONMENT": "production",
"CLOUD_DATABASE_URL": "postgresql://db/cloud",
"CLOUD_PUBLIC_CREDENTIALS_JSON": (
'[{"principal_id":"sdk","token":"secret","scopes":[]}]'
),
"CLOUD_SESSION_COOKIE_SECURE": "false",
}
)
@pytest.mark.parametrize(
"name,value",
[
("CLOUD_PUBLIC_CREDENTIALS_JSON", "not-json"),
("CLOUD_PUBLIC_CREDENTIALS_JSON", "{}"),
(
"CLOUD_HOST_CREDENTIALS_JSON",
'[{"principal_id":"agent","token":"secret","scopes":[]}]',
),
(
"CLOUD_ENROLLMENT_TOKENS_JSON",
'[{"principal_id":"installer","token":123}]',
),
],
)
def test_load_control_config_rejects_invalid_credentials(
name: str,
value: str,
) -> None:
with pytest.raises(CloudConfigurationError, match="credential") as error:
load_control_config({name: value})
assert "secret" not in str(error.value)
def test_removed_static_credential_variables_are_ignored() -> None:
config = load_control_config(
{
"CLOUD_PUBLIC_CREDENTIALS_JSON": "not-json",
"CLOUD_HOST_CREDENTIALS_JSON": "not-json",
"CLOUD_ENROLLMENT_TOKENS_JSON": "not-json",
"CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED": "not-a-bool",
}
)
assert config == CloudControlConfig()
def test_auth_provider_requires_explicit_anonymous_override() -> None:
secure_provider = create_auth_provider([], allow_insecure_anonymous=False)
insecure_provider = create_auth_provider([], allow_insecure_anonymous=True)
secure_provider = create_auth_provider(allow_insecure_anonymous=False)
insecure_provider = create_auth_provider(allow_insecure_anonymous=True)
assert isinstance(secure_provider, ConfiguredBearerAuthProvider)
assert isinstance(secure_provider, RejectingAuthProvider)
request = type("Request", (), {"headers": {}})()
assert secure_provider.authenticate(request) is None
assert isinstance(insecure_provider, NullAuthProvider)
+45 -24
View File
@@ -1,12 +1,21 @@
from __future__ import annotations
import json
from pathlib import Path
import yaml
ROOT = Path(__file__).resolve().parents[1]
REMOVED_CREDENTIAL_SETTINGS = {
"CLOUD_PUBLIC_CREDENTIALS_JSON",
"CLOUD_HOST_CREDENTIALS_JSON",
"CLOUD_ENROLLMENT_TOKENS_JSON",
"CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED",
"CLOUD_CONSOLE_STATIC_DIR",
"HOST_AGENT_HOST_ID",
"HOST_AGENT_TOKEN",
"HOST_AGENT_ENROLLMENT_TOKEN",
}
def test_compose_defines_database_control_plane_and_outbound_host_agent() -> None:
@@ -29,45 +38,57 @@ def test_compose_defines_database_control_plane_and_outbound_host_agent() -> Non
services["host-agent"]["environment"]["HOST_AGENT_CONTROL_PLANE_URL"]
== "http://cloud-api:8001"
)
assert services["host-agent"]["environment"]["AI_PLANNER_ENABLED"] == (
"${AI_PLANNER_ENABLED:-false}"
assert services["host-agent"]["environment"]["HOST_AGENT_IDENTITY_PATH"] == (
"${HOST_AGENT_IDENTITY_PATH:-/app/tasks/host_identity.json}"
)
assert (
services["cloud-api"]["environment"]["CLOUD_ENROLLMENT_TOKENS_JSON"]
== "${CLOUD_ENROLLMENT_TOKENS_JSON:-[]}"
)
assert (
services["host-agent"]["environment"]["HOST_AGENT_IDENTITY_PATH"]
== "${HOST_AGENT_IDENTITY_PATH:-/app/tasks/host_identity.json}"
)
assert "ports" not in services["host-agent"]
assert not (
set(services["cloud-api"]["environment"])
| set(services["host-agent"]["environment"])
) & REMOVED_CREDENTIAL_SETTINGS
def test_container_uses_locked_workspace_install_and_migrations() -> None:
def test_deploy_compose_has_only_cloud_services_and_minimal_environment() -> None:
compose = yaml.safe_load(
(ROOT / "compose.deploy.yaml").read_text(encoding="utf-8")
)
services = compose["services"]
assert set(services) == {"postgres", "cloud-api"}
assert services["cloud-api"]["image"].endswith(":${IMAGE_TAG:-latest}")
assert services["cloud-api"]["environment"] == {
"CLOUD_ENVIRONMENT": "production",
"CLOUD_DATABASE_URL": (
"postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}"
"@postgres:5432/${POSTGRES_DB}"
),
"CLOUD_TRUST_PROXY_HEADERS": "${CLOUD_TRUST_PROXY_HEADERS:-false}",
}
def test_container_uses_locked_workspace_install_migrations_and_static_console() -> None:
dockerfile = (ROOT / "Dockerfile").read_text(encoding="utf-8")
compose = yaml.safe_load((ROOT / "compose.yaml").read_text(encoding="utf-8"))
cloud_command = compose["services"]["cloud-api"]["command"][-1]
assert "uv sync --locked --all-packages --no-dev" in dockerfile
assert "CLOUD_CONSOLE_STATIC_DIR=/app/console-static" in dockerfile
assert "alembic" in cloud_command
assert "upgrade head" in cloud_command
assert "device-cloud-api --host 0.0.0.0" in cloud_command
def test_example_environment_contains_only_placeholder_credentials() -> None:
def test_example_environment_contains_no_static_credentials() -> None:
values = {}
for line in (ROOT / ".env.example").read_text(encoding="utf-8").splitlines():
if line and not line.startswith("#"):
name, value = line.split("=", 1)
values[name] = value
public_credentials = json.loads(values["CLOUD_PUBLIC_CREDENTIALS_JSON"])
host_credentials = json.loads(values["CLOUD_HOST_CREDENTIALS_JSON"])
enrollment_credentials = json.loads(values["CLOUD_ENROLLMENT_TOKENS_JSON"])
assert public_credentials[0]["token"].startswith("change-me-")
assert host_credentials[0]["token"] == values["HOST_AGENT_TOKEN"]
assert host_credentials[0]["token"].startswith("change-me-")
assert host_credentials[0]["host_id"] == values["HOST_AGENT_HOST_ID"]
assert enrollment_credentials[0]["token"].startswith("change-me-")
assert values["HOST_AGENT_IDENTITY_PATH"] == "/app/tasks/host_identity.json"
assert set(values) == {
"IMAGE_TAG",
"POSTGRES_DB",
"POSTGRES_USER",
"POSTGRES_PASSWORD",
"CLOUD_API_PORT",
}
assert not set(values) & REMOVED_CREDENTIAL_SETTINGS
Generated
+4
View File
@@ -566,14 +566,18 @@ source = { editable = "apps/device-host-agent" }
dependencies = [
{ name = "device-agent-runtime" },
{ name = "device-cloud-platform" },
{ name = "fastapi" },
{ name = "httpx" },
{ name = "uvicorn", extra = ["standard"] },
]
[package.metadata]
requires-dist = [
{ name = "device-agent-runtime", editable = "." },
{ name = "device-cloud-platform", editable = "packages/cloud-platform" },
{ name = "fastapi", specifier = ">=0.115.0" },
{ name = "httpx", specifier = ">=0.27.0" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" },
]
[[package]]