This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -12,3 +12,7 @@
|
||||
|
||||
__pycache__/
|
||||
tasks/
|
||||
|
||||
*.egg-info/
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
+2
-1
@@ -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"]
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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() == []
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
+43
@@ -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
|
||||
+43
@@ -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.
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]]
|
||||
|
||||
Reference in New Issue
Block a user