Compare commits

...
6 Commits
Author SHA1 Message Date
q792602257 d673e77171 Jenkins打包
Tests / Test No test results found
2026-07-13 14:00:43 +08:00
q792602257andClaude Opus 4.6 2169bb03d9 feat(cloud-console): task listing, attempt history, CORS, and console SPA
Implements the cloud-console OpenSpec change: adds GET /v1/tasks (filterable,
bounded pagination, tasks:read) and GET /v1/tasks/{id}/attempts (404 on unknown
task) to the platform SDK, with matching CloudClient methods and a closed-by-
default CLOUD_CONSOLE_CORS_ORIGINS allow-list wired through CloudControlConfig.
Ships an independent Vue 3 + Vite SPA at cloud-console/ that authenticates with
an operator-supplied bearer token held in sessionStorage, renders tasks with
attempt history, device pool, host registry, and the plugin registry with a
registration form.

Backend test suite: 438 passed (-m "not integration"); cloud-console typecheck
and production build both succeed. PostgreSQL-backed repository tests and
manual end-to-end verification remain pending external infrastructure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-13 14:00:23 +08:00
q792602257 62923b9285 docs(openspec): confirm android-driver UiAutomator2 commands via research
Resolve the previously-open design questions in the android-driver
change by researching the appium-uiautomator2-driver docs and Appium 3
release notes:

- tap -> mobile: clickGesture
- swipe -> mobile: dragGesture (duration_ms converted to speed px/s)
- home -> mobile: pressKey (KEYCODE_HOME)
- port isolation -> appium:systemPort capability
- Appium 3 breaking changes confirmed to not affect this design

Updates design.md (Decisions/Risks/Open Questions/Migration Plan) and
tasks.md (section 1 and tasks 2.1/2.4/4.1) accordingly.
2026-07-13 13:56:04 +08:00
q792602257 e61dcca801 feat(cloud): add edge host enrollment 2026-07-13 13:54:16 +08:00
q792602257 cd56facbbf chore(openspec): add android-driver proposal 2026-07-13 12:48:09 +08:00
q792602257 960361493c chore(openspec): add cloud-console proposal 2026-07-13 11:49:34 +08:00
75 changed files with 6068 additions and 58 deletions
+5 -3
View File
@@ -1,8 +1,6 @@
# Only used by compose.deploy.yaml (image: ${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}).
# Used by compose.deploy.yaml (image: git.jerryyan.net/q792602257/agentic-mobile-control:${IMAGE_TAG}).
# Jenkins publishes IMAGE_TAG as "<BUILD_NUMBER>-<git short sha>"; match the
# tag of the build you intend to deploy.
REGISTRY=git.jerryyan.net
IMAGE_NAME=q792602257/agentic-mobile-control
IMAGE_TAG=latest
POSTGRES_DB=device_cloud
@@ -12,6 +10,7 @@ POSTGRES_PASSWORD=change-me-database-password
CLOUD_API_PORT=8001
CLOUD_PUBLIC_CREDENTIALS_JSON=[{"principal_id":"local-sdk","token":"change-me-public-token","scopes":["tasks:submit","tasks:read","pool:read","plugins:read","plugins:admin"]}]
CLOUD_HOST_CREDENTIALS_JSON=[{"principal_id":"local-host-agent","token":"change-me-host-token","scopes":[],"host_id":"host-local"}]
CLOUD_ENROLLMENT_TOKENS_JSON=[{"principal_id":"edge-installer","token":"change-me-enrollment-token"}]
CLOUD_SCHEDULER_INTERVAL_SECONDS=1
CLOUD_LEASE_REAPER_INTERVAL_SECONDS=5
CLOUD_LEASE_DURATION_SECONDS=60
@@ -19,6 +18,9 @@ CLOUD_MAX_TASK_ATTEMPTS=3
HOST_AGENT_HOST_ID=host-local
HOST_AGENT_TOKEN=change-me-host-token
HOST_AGENT_ENROLLMENT_TOKEN=
HOST_AGENT_IDENTITY_PATH=/app/tasks/host_identity.json
HOST_AGENT_DISPLAY_NAME=
HOST_AGENT_TASKS_PATH=./tasks
HOST_AGENT_HEARTBEAT_INTERVAL_SECONDS=30
HOST_AGENT_POLL_TIMEOUT_SECONDS=20
+28 -2
View File
@@ -11,7 +11,12 @@ from fastapi import FastAPI, status
from fastapi import Request
from fastapi.responses import JSONResponse
from cloud.auth import create_auth_provider
from cloud.auth import (
ChainedAuthProvider,
ConfiguredEnrollmentTokenProvider,
RepositoryHostAuthProvider,
create_auth_provider,
)
from cloud.config import CloudConfig
from cloud.control_config import (
CloudControlConfig,
@@ -73,11 +78,20 @@ def create_app(
control_config = config or load_control_config()
validate_control_config(control_config)
build_database = database_factory or _default_database_factory
auth_provider = create_auth_provider(
configured_auth_provider = create_auth_provider(
control_config.credentials,
allow_insecure_anonymous=control_config.allow_insecure_anonymous,
)
repository = _RepositoryProxy()
auth_provider = ChainedAuthProvider(
(
configured_auth_provider,
RepositoryHostAuthProvider(repository), # type: ignore[arg-type]
)
)
enrollment_auth_provider = ConfiguredEnrollmentTokenProvider(
control_config.enrollment_credentials
)
domain_config = CloudConfig(
lease_duration_seconds=control_config.lease_duration_seconds,
)
@@ -144,6 +158,17 @@ def create_app(
app = FastAPI(title="Device Cloud API", lifespan=lifespan)
if control_config.cors_allowed_origins:
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=list(control_config.cors_allowed_origins),
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.middleware("http")
async def correlation_logging(request: Request, call_next):
correlation_id = normalize_correlation_id(
@@ -208,6 +233,7 @@ def create_app(
create_internal_router(
pool=pool,
auth_provider=auth_provider,
enrollment_auth_provider=enrollment_auth_provider,
lease_duration_seconds=control_config.lease_duration_seconds,
)
)
+228
View File
@@ -8,6 +8,7 @@ from fastapi.testclient import TestClient
import cloud_api.app as app_module
from cloud_api.app import create_app
from cloud.auth import BearerCredential, EnrollmentCredential, digest_token
from cloud.control_config import CloudConfigurationError, CloudControlConfig
from cloud.database import CloudDatabase
from cloud.pool import PooledDevice
@@ -23,6 +24,156 @@ def test_create_app_returns_independent_cloud_application() -> None:
paths = set(app.openapi()["paths"])
assert "/v1/tasks" in paths
assert "/internal/v1/hosts/{host_id}/heartbeat" in paths
assert "/internal/v1/enrollments" in paths
assert "/internal/v1/hosts/{host_id}/devices/enroll" in paths
def test_managed_host_enrollment_device_mapping_and_restart_authentication(
tmp_path,
) -> None:
database_url = f"sqlite:///{(tmp_path / 'enrollment.sqlite3').as_posix()}"
enrollment_token = "one-time-enrollment-token"
host_token = "host-token-" + ("x" * 40)
config = CloudControlConfig(
database_url=database_url,
credentials=(
BearerCredential(
principal_id="operator",
token="operator-token",
scopes=frozenset({"pool:read"}),
),
),
enrollment_credentials=(
EnrollmentCredential(
principal_id="installer-a",
token=enrollment_token,
),
),
)
enrollment_payload = {
"agent_instance_id": "agent-instance-a",
"host_token": host_token,
"display_name": "Edge Mac",
}
app = create_app(config=config)
with TestClient(app) as client:
enrolled = client.post(
"/internal/v1/enrollments",
headers={"Authorization": f"Bearer {enrollment_token}"},
json=enrollment_payload,
)
assert enrolled.status_code == 201
host_id = enrolled.json()["host_id"]
assert host_id.startswith("host-")
retried = client.post(
"/internal/v1/enrollments",
headers={"Authorization": f"Bearer {enrollment_token}"},
json=enrollment_payload,
)
assert retried.status_code == 201
assert retried.json()["host_id"] == host_id
reused = client.post(
"/internal/v1/enrollments",
headers={"Authorization": f"Bearer {enrollment_token}"},
json={
**enrollment_payload,
"agent_instance_id": "agent-instance-b",
},
)
assert reused.status_code == 409
device = client.post(
f"/internal/v1/hosts/{host_id}/devices/enroll",
headers={"Authorization": f"Bearer {host_token}"},
json={
"local_device_id": "local-device-a",
"driver_type": "wda",
"name": "iPhone",
"capability_tags": ["ios"],
},
)
assert device.status_code == 201
device_id = device.json()["device_id"]
assert device_id.startswith("device-")
device_retry = client.post(
f"/internal/v1/hosts/{host_id}/devices/enroll",
headers={"Authorization": f"Bearer {host_token}"},
json={
"local_device_id": "local-device-a",
"driver_type": "wda",
"name": "Renamed iPhone",
"capability_tags": ["ios", "physical"],
},
)
assert device_retry.json()["device_id"] == device_id
rejected_snapshot = client.put(
f"/internal/v1/hosts/{host_id}/heartbeat",
headers={"Authorization": f"Bearer {host_token}"},
json={
"host_id": host_id,
"devices": [
{
"device_id": "caller-selected-device",
"driver_type": "wda",
"status": "idle",
}
],
},
)
assert rejected_snapshot.status_code == 409
heartbeat = client.put(
f"/internal/v1/hosts/{host_id}/heartbeat",
headers={"Authorization": f"Bearer {host_token}"},
json={
"host_id": host_id,
"devices": [
{
"device_id": device_id,
"driver_type": "wda",
"status": "idle",
}
],
},
)
assert heartbeat.status_code == 200
public_attempt = client.get(
"/v1/devices",
headers={"Authorization": f"Bearer {host_token}"},
)
assert public_attempt.status_code == 403
restarted = create_app(config=config)
with TestClient(restarted) as client:
heartbeat = client.put(
f"/internal/v1/hosts/{host_id}/heartbeat",
headers={"Authorization": f"Bearer {host_token}"},
json={"host_id": host_id, "devices": []},
)
assert heartbeat.status_code == 200
assert (
restarted.state.cloud_services.repository.authenticate_enrolled_host(
digest_token(host_token)
)
== host_id
)
assert restarted.state.cloud_services.repository.revoke_enrolled_host(
host_id,
revoked_at=utc_now(),
)
rejected = client.put(
f"/internal/v1/hosts/{host_id}/heartbeat",
headers={"Authorization": f"Bearer {host_token}"},
json={"host_id": host_id, "devices": []},
)
assert rejected.status_code == 401
def test_cloud_application_owns_database_lifecycle() -> None:
@@ -457,3 +608,80 @@ def test_production_app_rejects_missing_credentials() -> None:
database_url="postgresql://db/cloud",
)
)
def test_cors_headers_are_absent_when_allow_list_is_empty() -> None:
app = create_app(config=CloudControlConfig(database_url="sqlite:///:memory:"))
with TestClient(app) as client:
response = client.options(
"/health/live",
headers={
"Origin": "http://console.example",
"Access-Control-Request-Method": "GET",
},
)
assert response.status_code >= 400
assert "access-control-allow-origin" not in {
key.lower() for key in response.headers
}
def test_cors_headers_reflect_configured_origin_only() -> None:
app = create_app(
config=CloudControlConfig(
database_url="sqlite:///:memory:",
cors_allowed_origins=("http://console.example",),
)
)
with TestClient(app) as client:
allowed = client.options(
"/health/live",
headers={
"Origin": "http://console.example",
"Access-Control-Request-Method": "GET",
},
)
blocked = client.options(
"/health/live",
headers={
"Origin": "http://attacker.example",
"Access-Control-Request-Method": "GET",
},
)
assert allowed.status_code in {200, 204}
assert allowed.headers["access-control-allow-origin"] == "http://console.example"
# An origin that is not on the allow-list must not be echoed back.
assert (
blocked.headers.get("access-control-allow-origin") != "http://attacker.example"
)
def test_load_control_config_parses_cors_allow_list() -> None:
from cloud.control_config import load_control_config
config = load_control_config(
env={
"CLOUD_ENVIRONMENT": "local",
"CLOUD_DATABASE_URL": "sqlite:///:memory:",
"CLOUD_CONSOLE_CORS_ORIGINS": (
"http://console.example, https://console.example"
),
}
)
assert config.cors_allowed_origins == (
"http://console.example",
"https://console.example",
)
def test_load_control_config_defaults_to_empty_cors_allow_list() -> None:
from cloud.control_config import load_control_config
config = load_control_config(
env={
"CLOUD_ENVIRONMENT": "local",
"CLOUD_DATABASE_URL": "sqlite:///:memory:",
}
)
assert config.cors_allowed_origins == ()
+41 -7
View File
@@ -8,10 +8,12 @@ from cloud.internal_api.models import AssignmentModel
from device.manager import DeviceManager
from driver.registry import build_driver_factory
from host_agent.assignment import AssignmentExecutor
from host_agent.client import HostAgentClient
from host_agent.client import HostAgentClient, HostAgentEnrollmentClient
from host_agent.config import HostAgentConfig, load_host_agent_config
from host_agent.enrollment import resolve_host_identity
from host_agent.execution import create_execution_factories
from host_agent.heartbeat import HeartbeatSynchronizer
from host_agent.identity import HostIdentityStore
from host_agent.lease import ActiveAssignmentRunner
from host_agent.processor import AssignmentProcessingResult, AssignmentProcessor
from storage.device_config import DeviceConfigStore
@@ -97,9 +99,29 @@ def create_application(
config: HostAgentConfig | None = None,
manager: DeviceManager | None = None,
device_config_store: DeviceConfigStore | None = None,
identity_store: HostIdentityStore | None = None,
enrollment_client: HostAgentEnrollmentClient | None = None,
) -> HostAgentApplication:
resolved_config = config or load_host_agent_config()
resolved_manager = manager or _configured_device_manager(device_config_store)
startup_config = config or load_host_agent_config()
config_store = device_config_store or DeviceConfigStore()
owned_enrollment_client = enrollment_client is None
bootstrap_client = enrollment_client or HostAgentEnrollmentClient(startup_config)
try:
resolved_config = resolve_host_identity(
startup_config,
identity_store=identity_store
or HostIdentityStore(startup_config.identity_path),
client=bootstrap_client,
)
bootstrap_client.config = resolved_config
resolved_manager = manager or _configured_device_manager(
config_store,
config=resolved_config,
enrollment_client=bootstrap_client,
)
finally:
if owned_enrollment_client:
bootstrap_client.close()
client = HostAgentClient(resolved_config)
heartbeat = HeartbeatSynchronizer(resolved_manager, client, resolved_config)
executor = AssignmentExecutor(create_execution_factories(resolved_manager))
@@ -112,13 +134,25 @@ def create_application(
def _configured_device_manager(
config_store: DeviceConfigStore | None,
config_store: DeviceConfigStore,
*,
config: HostAgentConfig,
enrollment_client: HostAgentEnrollmentClient,
) -> DeviceManager:
manager = DeviceManager()
store = config_store or DeviceConfigStore()
for device in store.list():
for device in config_store.list():
runtime_device_id = device["device_id"]
if config.enrollment_managed:
enrollment = enrollment_client.enroll_device(
local_device_id=device["device_id"],
driver_type=device["driver_type"],
name=device["name"],
capability_tags=[],
)
runtime_device_id = enrollment.device_id
config_store.set_cloud_device_id(device["device_id"], runtime_device_id)
manager.register_device(
device["device_id"],
runtime_device_id,
build_driver_factory(
device["driver_type"],
device["connection_info"],
+111 -13
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import time
from collections.abc import Awaitable, Callable
from typing import Any
@@ -9,8 +10,10 @@ import httpx
from cloud.internal_api.models import (
AssignmentModel,
ClaimResponse,
DeviceEnrollmentResponse,
DeviceSnapshotModel,
HeartbeatResponse,
HostEnrollmentResponse,
LeaseRenewalResponse,
TerminalResultResponse,
)
@@ -30,6 +33,99 @@ class StaleLeaseError(HostAgentAPIError):
pass
class HostAgentEnrollmentClient:
def __init__(
self,
config: HostAgentConfig,
*,
http_client: httpx.Client | None = None,
sleep: Callable[[float], None] = time.sleep,
) -> None:
self.config = config
self._sleep = sleep
self._owns_client = http_client is None
self._client = http_client or httpx.Client(base_url=config.control_plane_url)
def enroll_host(
self,
*,
agent_instance_id: str,
host_token: str,
display_name: str | None,
) -> HostEnrollmentResponse:
if not self.config.enrollment_token:
raise HostAgentAPIError(0, "Host enrollment token is unavailable")
response = self._request(
"POST",
"/internal/v1/enrollments",
token=self.config.enrollment_token,
json={
"agent_instance_id": agent_instance_id,
"host_token": host_token,
"display_name": display_name,
},
)
return HostEnrollmentResponse.model_validate(response.json())
def enroll_device(
self,
*,
local_device_id: str,
driver_type: str,
name: str | None,
capability_tags: list[str],
) -> DeviceEnrollmentResponse:
if not self.config.host_id or not self.config.token:
raise HostAgentAPIError(0, "Host identity is unresolved")
response = self._request(
"POST",
f"/internal/v1/hosts/{self.config.host_id}/devices/enroll",
token=self.config.token,
json={
"local_device_id": local_device_id,
"driver_type": driver_type,
"name": name,
"capability_tags": list(capability_tags),
},
)
return DeviceEnrollmentResponse.model_validate(response.json())
def close(self) -> None:
if self._owns_client:
self._client.close()
def _request(
self,
method: str,
path: str,
*,
token: str,
json: dict[str, Any],
) -> httpx.Response:
backoff = self.config.retry_backoff_seconds
for attempt in range(1, self.config.max_retry_attempts + 1):
try:
response = self._client.request(
method,
path,
json=json,
headers={"Authorization": f"Bearer {token}"},
)
except httpx.TransportError:
if attempt == self.config.max_retry_attempts:
raise
else:
if response.status_code < 500:
if response.is_success:
return response
_raise_api_error(response)
if attempt == self.config.max_retry_attempts:
_raise_api_error(response)
self._sleep(backoff)
backoff = min(backoff * 2, self.config.max_retry_backoff_seconds)
raise AssertionError("retry loop exited unexpectedly")
class HostAgentClient:
def __init__(
self,
@@ -149,22 +245,24 @@ class HostAgentClient:
if response.status_code < 500:
if response.is_success:
return response
self._raise_api_error(response)
_raise_api_error(response, stale_lease=True)
if attempt == self.config.max_retry_attempts:
self._raise_api_error(response)
_raise_api_error(response, stale_lease=True)
await self._sleep(backoff)
backoff = min(backoff * 2, self.config.max_retry_backoff_seconds)
raise AssertionError("retry loop exited unexpectedly")
@staticmethod
def _raise_api_error(response: httpx.Response) -> None:
try:
payload = response.json()
except ValueError:
payload = {}
detail = payload.get("detail") or payload.get("code") or "request rejected"
error_type = (
StaleLeaseError if response.status_code == 409 else HostAgentAPIError
)
raise error_type(response.status_code, str(detail))
def _raise_api_error(response: httpx.Response, *, stale_lease: bool = False) -> None:
try:
payload = response.json()
except ValueError:
payload = {}
detail = payload.get("detail") or payload.get("code") or "request rejected"
error_type = (
StaleLeaseError
if stale_lease and response.status_code == 409
else HostAgentAPIError
)
raise error_type(response.status_code, str(detail))
+25 -7
View File
@@ -2,7 +2,8 @@ from __future__ import annotations
import os
from collections.abc import Mapping
from dataclasses import dataclass
from dataclasses import dataclass, field
from pathlib import Path
from urllib.parse import urlparse
@@ -13,8 +14,12 @@ class HostAgentConfigurationError(ValueError):
@dataclass(frozen=True)
class HostAgentConfig:
control_plane_url: str
host_id: str
token: str
host_id: str = ""
token: str = field(default="", repr=False)
enrollment_token: str = field(default="", repr=False)
identity_path: Path = Path("tasks/host_identity.json")
enrollment_managed: bool = False
display_name: str | None = None
heartbeat_interval_seconds: float = 30.0
poll_timeout_seconds: float = 20.0
retry_backoff_seconds: float = 1.0
@@ -41,16 +46,29 @@ def load_host_agent_config(
)
host_id = values.get("HOST_AGENT_HOST_ID", "").strip()
if not host_id:
raise HostAgentConfigurationError("HOST_AGENT_HOST_ID is required")
token = values.get("HOST_AGENT_TOKEN", "").strip()
if not token:
raise HostAgentConfigurationError("HOST_AGENT_TOKEN is required")
if bool(host_id) != bool(token):
raise HostAgentConfigurationError(
"HOST_AGENT_HOST_ID and HOST_AGENT_TOKEN must be configured together"
)
enrollment_token = values.get("HOST_AGENT_ENROLLMENT_TOKEN", "").strip()
identity_path = Path(
values.get("HOST_AGENT_IDENTITY_PATH", "tasks/host_identity.json").strip()
)
if not host_id and not enrollment_token and not identity_path.is_file():
raise HostAgentConfigurationError(
"explicit Host credentials, an enrollment token, or existing identity state "
"is required"
)
config = HostAgentConfig(
control_plane_url=control_plane_url,
host_id=host_id,
token=token,
enrollment_token=enrollment_token,
identity_path=identity_path,
enrollment_managed=not bool(host_id),
display_name=values.get("HOST_AGENT_DISPLAY_NAME") or None,
heartbeat_interval_seconds=_positive_float(
values,
"HOST_AGENT_HEARTBEAT_INTERVAL_SECONDS",
@@ -0,0 +1,35 @@
from __future__ import annotations
from dataclasses import replace
from host_agent.client import HostAgentEnrollmentClient
from host_agent.config import HostAgentConfig, HostAgentConfigurationError
from host_agent.identity import HostIdentityStore
def resolve_host_identity(
config: HostAgentConfig,
*,
identity_store: HostIdentityStore,
client: HostAgentEnrollmentClient,
) -> HostAgentConfig:
if config.host_id and config.token:
return config
state = identity_store.load_or_create()
if state.host_id is None:
if not config.enrollment_token:
raise HostAgentConfigurationError(
"HOST_AGENT_ENROLLMENT_TOKEN is required to complete enrollment"
)
response = client.enroll_host(
agent_instance_id=state.agent_instance_id,
host_token=state.token,
display_name=config.display_name,
)
state = identity_store.complete(state, response.host_id)
return replace(
config,
host_id=state.host_id or "",
token=state.token,
enrollment_managed=True,
)
@@ -0,0 +1,101 @@
from __future__ import annotations
import json
import os
from dataclasses import dataclass, field, replace
from pathlib import Path
from secrets import token_urlsafe
from uuid import uuid4
class HostIdentityStateError(RuntimeError):
"""Raised when persisted Host enrollment identity is missing or invalid."""
@dataclass(frozen=True)
class HostIdentityState:
agent_instance_id: str
token: str = field(repr=False)
host_id: str | None = None
class HostIdentityStore:
def __init__(self, path: str | Path) -> None:
self.path = Path(path)
def load(self) -> HostIdentityState | None:
if not self.path.exists():
return None
try:
payload = json.loads(self.path.read_text(encoding="utf-8"))
except (OSError, ValueError, json.JSONDecodeError) as exc:
raise HostIdentityStateError("Host identity state is unreadable") from exc
if not isinstance(payload, dict):
raise HostIdentityStateError("Host identity state must be an object")
agent_instance_id = payload.get("agent_instance_id")
token = payload.get("token")
host_id = payload.get("host_id")
if (
not isinstance(agent_instance_id, str)
or not agent_instance_id
or not isinstance(token, str)
or len(token) < 32
or (host_id is not None and (not isinstance(host_id, str) or not host_id))
):
raise HostIdentityStateError("Host identity state is invalid")
return HostIdentityState(
agent_instance_id=agent_instance_id,
token=token,
host_id=host_id,
)
def load_or_create(self) -> HostIdentityState:
existing = self.load()
if existing is not None:
return existing
state = HostIdentityState(
agent_instance_id=f"agent-{uuid4().hex}",
token=token_urlsafe(48),
)
self._write(state)
return state
def complete(self, state: HostIdentityState, host_id: str) -> HostIdentityState:
if not host_id:
raise ValueError("host_id must not be empty")
current = self.load()
if current is not None and (
current.agent_instance_id != state.agent_instance_id
or current.token != state.token
):
raise HostIdentityStateError("Host identity changed during enrollment")
completed = replace(state, host_id=host_id)
self._write(completed)
return completed
def _write(self, state: HostIdentityState) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
temporary = self.path.with_name(f".{self.path.name}.{uuid4().hex}.tmp")
payload = {
"agent_instance_id": state.agent_instance_id,
"token": state.token,
"host_id": state.host_id,
}
try:
temporary.write_text(
json.dumps(payload, ensure_ascii=True, indent=2) + "\n",
encoding="utf-8",
)
_restrict_permissions(temporary)
os.replace(temporary, self.path)
_restrict_permissions(self.path)
finally:
if temporary.exists():
temporary.unlink()
def _restrict_permissions(path: Path) -> None:
try:
path.chmod(0o600)
except OSError:
return
+106 -1
View File
@@ -4,10 +4,15 @@ import asyncio
from contextlib import suppress
from datetime import UTC, datetime, timedelta
from cloud.internal_api.models import AssignmentModel
from cloud.internal_api.models import (
AssignmentModel,
DeviceEnrollmentResponse,
HostEnrollmentResponse,
)
from device.manager import DeviceManager
from host_agent.app import HostAgentApplication, create_application
from host_agent.config import HostAgentConfig
from host_agent.identity import HostIdentityStore
from storage.device_config import DeviceConfigStore
@@ -65,6 +70,106 @@ def test_create_application_loads_persisted_device_configuration(
asyncio.run(application.client.aclose())
def test_create_application_enrolls_host_and_devices_before_managed_startup(
tmp_path,
monkeypatch,
) -> None:
monkeypatch.chdir(tmp_path)
store = DeviceConfigStore(tmp_path / "devices.sqlite3")
store.add(
device_id="local-device-a",
name="Lab iPhone",
driver_type="wda",
connection_info={"server_url": "http://127.0.0.1:4723"},
)
events: list[str] = []
class EnrollmentClient:
def __init__(self) -> None:
self.config = HostAgentConfig(
control_plane_url="https://control.example",
enrollment_token="one-time-token",
enrollment_managed=True,
)
def enroll_host(self, **payload):
events.append(f"host:{payload['agent_instance_id']}")
return HostEnrollmentResponse(host_id="host-cloud-a")
def enroll_device(self, **payload):
events.append(f"device:{payload['local_device_id']}")
return DeviceEnrollmentResponse(device_id="device-cloud-a")
def close(self):
raise AssertionError("injected client must not be closed")
identity_store = HostIdentityStore(tmp_path / "host_identity.json")
enrollment_client = EnrollmentClient()
application = create_application(
config=enrollment_client.config,
device_config_store=store,
identity_store=identity_store,
enrollment_client=enrollment_client, # type: ignore[arg-type]
)
assert events[0].startswith("host:agent-")
assert events[1] == "device:local-device-a"
assert application.client.config.host_id == "host-cloud-a"
assert application.client.config.enrollment_managed is True
assert [device.id for device in application.heartbeat.manager.list_devices()] == [
"device-cloud-a"
]
assert store.get("local-device-a")["cloud_device_id"] == "device-cloud-a"
assert identity_store.load().host_id == "host-cloud-a"
asyncio.run(application.client.aclose())
def test_managed_restart_reuses_identity_and_recovers_device_mapping(
tmp_path,
monkeypatch,
) -> None:
monkeypatch.chdir(tmp_path)
store = DeviceConfigStore(tmp_path / "devices.sqlite3")
store.add(
device_id="local-device-a",
driver_type="wda",
connection_info={},
)
identity_store = HostIdentityStore(tmp_path / "host_identity.json")
identity_store.complete(identity_store.load_or_create(), "host-cloud-a")
events: list[str] = []
class EnrollmentClient:
config = HostAgentConfig(
control_plane_url="https://control.example",
identity_path=tmp_path / "host_identity.json",
enrollment_managed=True,
)
def enroll_host(self, **payload):
raise AssertionError("completed identity must skip Host enrollment")
def enroll_device(self, **payload):
events.append(payload["local_device_id"])
return DeviceEnrollmentResponse(device_id="device-cloud-a")
def close(self):
return None
enrollment_client = EnrollmentClient()
application = create_application(
config=enrollment_client.config,
device_config_store=store,
identity_store=identity_store,
enrollment_client=enrollment_client, # type: ignore[arg-type]
)
assert events == ["local-device-a"]
assert application.client.config.host_id == "host-cloud-a"
assert store.get("local-device-a")["cloud_device_id"] == "device-cloud-a"
asyncio.run(application.client.aclose())
def test_shutdown_cancels_long_poll_and_sends_final_heartbeat() -> None:
async def scenario() -> None:
claim_started = asyncio.Event()
+60 -1
View File
@@ -8,7 +8,11 @@ import httpx
import pytest
from cloud.internal_api.models import AssignmentModel, DeviceSnapshotModel
from host_agent.client import HostAgentClient, StaleLeaseError
from host_agent.client import (
HostAgentClient,
HostAgentEnrollmentClient,
StaleLeaseError,
)
from host_agent.config import HostAgentConfig
@@ -166,3 +170,58 @@ def test_result_report_retries_identical_payload_after_response_loss() -> None:
assert len(payloads) == 2
assert payloads[0] == payloads[1]
assert payloads[0]["failure_reason"] == "planner unavailable"
def test_bootstrap_client_retries_identical_enrollment_and_enrolls_device() -> None:
requests: list[httpx.Request] = []
host_attempts = 0
def handler(request: httpx.Request) -> httpx.Response:
nonlocal host_attempts
requests.append(request)
if request.url.path == "/internal/v1/enrollments":
host_attempts += 1
if host_attempts == 1:
raise httpx.ReadError("response lost", request=request)
return httpx.Response(201, json={"host_id": "host-cloud-a"})
return httpx.Response(201, json={"device_id": "device-cloud-a"})
config = _config(
host_id="",
token="",
enrollment_token="one-time-token",
enrollment_managed=True,
)
with httpx.Client(
transport=httpx.MockTransport(handler),
base_url="https://control.example",
) as http_client:
client = HostAgentEnrollmentClient(
config,
http_client=http_client,
sleep=lambda _delay: None,
)
host = client.enroll_host(
agent_instance_id="agent-instance-a",
host_token="host-token-" + ("x" * 40),
display_name="Edge Mac",
)
client.config = _config(
host_id=host.host_id,
token="host-token-" + ("x" * 40),
enrollment_token="one-time-token",
enrollment_managed=True,
)
device = client.enroll_device(
local_device_id="local-device-a",
driver_type="wda",
name="iPhone",
capability_tags=["ios"],
)
assert host.host_id == "host-cloud-a"
assert device.device_id == "device-cloud-a"
assert len(requests) == 3
assert requests[0].content == requests[1].content
assert requests[0].headers["authorization"] == "Bearer one-time-token"
assert requests[2].headers["authorization"] == ("Bearer host-token-" + ("x" * 40))
@@ -1,5 +1,7 @@
from __future__ import annotations
from pathlib import Path
import pytest
from host_agent.config import (
@@ -40,11 +42,44 @@ def test_load_host_agent_config_parses_poll_and_retry_values() -> None:
assert config.max_retry_backoff_seconds == 20
def test_load_host_agent_config_supports_managed_enrollment(tmp_path) -> None:
identity_path = tmp_path / "host_identity.json"
config = load_host_agent_config(
{
"HOST_AGENT_CONTROL_PLANE_URL": "https://cloud.example",
"HOST_AGENT_ENROLLMENT_TOKEN": "one-time-token",
"HOST_AGENT_IDENTITY_PATH": str(identity_path),
"HOST_AGENT_DISPLAY_NAME": "Edge Mac",
}
)
assert config.host_id == ""
assert config.token == ""
assert config.enrollment_token == "one-time-token"
assert config.identity_path == identity_path
assert config.enrollment_managed is True
assert config.display_name == "Edge Mac"
assert "one-time-token" not in repr(config)
def test_existing_identity_state_allows_restart_without_enrollment_token(
tmp_path,
) -> None:
identity_path = tmp_path / "host_identity.json"
identity_path.write_text("{}", encoding="utf-8")
config = load_host_agent_config({"HOST_AGENT_IDENTITY_PATH": str(identity_path)})
assert config.identity_path == Path(identity_path)
assert config.enrollment_managed is True
@pytest.mark.parametrize(
"overrides",
[
{"HOST_AGENT_HOST_ID": ""},
{"HOST_AGENT_TOKEN": ""},
{"HOST_AGENT_HOST_ID": "host-a", "HOST_AGENT_TOKEN": ""},
{"HOST_AGENT_CONTROL_PLANE_URL": "ftp://cloud.example"},
{"HOST_AGENT_POLL_TIMEOUT_SECONDS": "0"},
{
@@ -0,0 +1,46 @@
from __future__ import annotations
import os
import pytest
from host_agent.identity import (
HostIdentityStateError,
HostIdentityStore,
)
def test_identity_store_persists_pending_and_completed_state(tmp_path) -> None:
path = tmp_path / "state" / "host_identity.json"
store = HostIdentityStore(path)
pending = store.load_or_create()
assert pending.host_id is None
assert len(pending.token) >= 32
assert "token=" not in repr(pending)
assert store.load() == pending
completed = store.complete(pending, "host-cloud-a")
assert completed.host_id == "host-cloud-a"
assert store.load() == completed
assert "host-cloud-a" in path.read_text(encoding="utf-8")
if os.name != "nt":
assert path.stat().st_mode & 0o777 == 0o600
def test_identity_store_rejects_invalid_or_changed_state(tmp_path) -> None:
path = tmp_path / "host_identity.json"
path.write_text('{"agent_instance_id":"a"}', encoding="utf-8")
store = HostIdentityStore(path)
with pytest.raises(HostIdentityStateError):
store.load()
path.unlink()
pending = store.load_or_create()
path.write_text(
'{"agent_instance_id":"other","token":"' + ("x" * 40) + '"}',
encoding="utf-8",
)
with pytest.raises(HostIdentityStateError, match="changed"):
store.complete(pending, "host-a")
+1
View File
@@ -0,0 +1 @@
VITE_CLOUD_API_BASE_URL=http://127.0.0.1:8001
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.DS_Store
*.local
+95
View File
@@ -0,0 +1,95 @@
# Cloud Console
Independent Vue 3 + Vite single-page app for the Cloud Control Plane
(`apps/cloud-api`). Operators authenticate by pasting a pre-issued scoped
bearer token; the console stores it in `sessionStorage`, attaches
`Authorization: Bearer <token>` to every request, and clears it whenever the
Cloud API responds `401` or `403`.
The app talks only to the platform SDK surface (`/v1/...`) and consumes the
two listing endpoints added by the `cloud-console` change (`GET /v1/tasks`,
`GET /v1/tasks/{task_id}/attempts`) alongside the existing
`/v1/devices`, `/v1/hosts`, `/v1/plugins`, and `POST /v1/plugins` routes.
## Prerequisites
- Node.js 20+ (matching the existing `console/` SPA project)
- A running Cloud API (`apps/cloud-api`) reachable from your browser
- A bearer token issued via `CLOUD_PUBLIC_CREDENTIALS_JSON` whose scopes cover
what you intend to do from the console. Recommended least-privilege set:
- `tasks:read` — task list and attempt history views
- `pool:read` — device and host views
- `plugins:read` — plugin list
- Add `tasks:submit`/`plugins:admin` only if you need the write actions from
the same tab.
## Configure the backend CORS allow-list
The Cloud API has no CORS middleware by default. Before a browser can call it
cross-origin, set `CLOUD_CONSOLE_CORS_ORIGINS` to a comma-separated allow-list
that includes the exact origin your dev server prints (scheme + host + port —
no trailing slash):
```bash
# Example: allow the default Vite dev origin
export CLOUD_CONSOLE_CORS_ORIGINS="http://127.0.0.1:5173"
```
Restart `apps/cloud-api` after changing this env. Tokens are still required —
the allow-list only says which browser origins may send them.
## Run the dev server
```bash
cd cloud-console
cp .env.example .env.local
# Edit .env.local if your Cloud API is not at http://127.0.0.1:8001
npm install
npm run dev
```
Vite prints a local URL (default `http://127.0.0.1:5173`). Open it, paste a
bearer token, and the task/device/host/plugin dashboards become available.
`.env.local` overrides the default base URL via `VITE_CLOUD_API_BASE_URL`
(defaults to `http://127.0.0.1:8001`).
## Build for production
```bash
npm run build # type-checks with vue-tsc, then emits dist/
npm run preview # serves the built bundle locally
```
`dist/` is a static bundle — host it behind any static file server or CDN and
point it at a deployed Cloud API via `VITE_CLOUD_API_BASE_URL` set at build
time.
## Token handling
- The token is held in `sessionStorage` only. Closing the tab discards it.
- Every API request attaches `Authorization: Bearer <token>` and targets only
the configured `VITE_CLOUD_API_BASE_URL`.
- A `401`/`403` response clears the stored token and returns the operator to
the token-entry screen with the API's error detail.
## Project layout
```
cloud-console/
├── src/
│ ├── api.ts # API client wrapper (token storage, fetch, errors)
│ ├── types.ts # TS interfaces mirroring the REST models
│ ├── App.vue # Shell: token gate, nav, view router
│ ├── main.ts # Vue bootstrap
│ ├── style.css # Dark theme styles
│ └── views/
│ ├── TokenScreen.vue
│ ├── TasksView.vue # list + detail with attempt history
│ ├── DevicesView.vue # device pool + host registry
│ └── PluginsView.vue # registry list + registration form
├── index.html
├── package.json
├── tsconfig.json / tsconfig.node.json
└── vite.config.ts
```
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Cloud Console</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1211
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "cloud-console",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview --host 127.0.0.1",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"@lucide/vue": "^1.23.0",
"vue": "^3.5.39"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.7",
"typescript": "^6.0.3",
"vite": "^8.1.3",
"vue-tsc": "^3.3.6"
}
}
+116
View File
@@ -0,0 +1,116 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from "vue";
import type { Component } from "vue";
import {
Boxes,
ListChecks,
LogOut,
MonitorSmartphone,
Puzzle,
} from "@lucide/vue";
import {
TOKEN_INVALID_EVENT,
clearStoredToken,
getStoredToken,
} from "./api";
import TokenScreen from "./views/TokenScreen.vue";
import TasksView from "./views/TasksView.vue";
import DevicesView from "./views/DevicesView.vue";
import PluginsView from "./views/PluginsView.vue";
type ViewId = "tasks" | "devices" | "plugins";
const navItems: { id: ViewId; label: string; icon: Component }[] = [
{ id: "tasks", label: "Tasks", icon: ListChecks },
{ id: "devices", label: "Devices", icon: MonitorSmartphone },
{ id: "plugins", label: "Plugins", icon: Puzzle },
];
const activeView = ref<ViewId>("tasks");
const tokenRejectedMessage = ref("");
const hasToken = ref(false);
function refreshTokenState() {
hasToken.value = getStoredToken() !== null;
}
function onTokenInvalid() {
hasToken.value = false;
tokenRejectedMessage.value =
"the cloud api rejected the stored token (401/403). paste a new token to continue.";
}
function onStorage(event: StorageEvent) {
if (event.key === null) {
// Tab-wide sessionStorage clear (some browsers fire this on logout).
refreshTokenState();
}
}
function signOut() {
clearStoredToken();
hasToken.value = false;
tokenRejectedMessage.value = "";
}
onMounted(() => {
refreshTokenState();
window.addEventListener(TOKEN_INVALID_EVENT, onTokenInvalid as EventListener);
window.addEventListener("storage", onStorage as EventListener);
});
onUnmounted(() => {
window.removeEventListener(TOKEN_INVALID_EVENT, onTokenInvalid as EventListener);
window.removeEventListener("storage", onStorage as EventListener);
});
const activeComponent = computed(() => {
switch (activeView.value) {
case "tasks":
return TasksView;
case "devices":
return DevicesView;
case "plugins":
return PluginsView;
}
return TasksView;
});
function onTokenSubmitted() {
tokenRejectedMessage.value = "";
refreshTokenState();
}
</script>
<template>
<TokenScreen
v-if="!hasToken"
:rejection-message="tokenRejectedMessage"
@submitted="onTokenSubmitted"
/>
<div v-else class="app-shell">
<nav class="app-nav">
<h1>
<Boxes :size="14" />
Cloud Console
</h1>
<button
v-for="item in navItems"
:key="item.id"
:class="{ active: activeView === item.id }"
@click="activeView = item.id"
>
<component :is="item.icon" :size="14" />
{{ item.label }}
</button>
<div class="spacer" />
<button @click="signOut">
<LogOut :size="14" />
Clear token
</button>
</nav>
<main class="app-main">
<component :is="activeComponent" />
</main>
</div>
</template>
+143
View File
@@ -0,0 +1,143 @@
import type {
DeviceRecord,
HostRecord,
PluginRecord,
PluginRegistrationPayload,
TaskAttempt,
TaskListResponse,
TaskStatus,
} from "./types";
const configuredBaseUrl = import.meta.env.VITE_CLOUD_API_BASE_URL as
| string
| undefined;
export const API_BASE_URL = (
configuredBaseUrl || "http://127.0.0.1:8001"
).replace(/\/$/, "");
const TOKEN_STORAGE_KEY = "cloudConsole.bearerToken";
export const TOKEN_INVALID_EVENT = "cloud-console:token-invalid";
export class CloudApiError extends Error {
readonly status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
this.name = "CloudApiError";
}
}
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);
}
interface RequestInitLike {
method?: string;
body?: string | null;
headers?: Record<string, string>;
}
async function request<T>(path: string, init: RequestInitLike = {}): Promise<T> {
const token = getStoredToken();
if (!token) {
throw new CloudApiError(401, "no bearer token stored");
}
const headers: Record<string, string> = {
Accept: "application/json",
Authorization: `Bearer ${token}`,
...init.headers,
};
if (init.body !== undefined && init.body !== null) {
headers["Content-Type"] = "application/json";
}
const response = await fetch(`${API_BASE_URL}${path}`, {
method: init.method || "GET",
body: init.body ?? null,
headers,
});
if (response.status === 401 || response.status === 403) {
clearStoredToken();
window.dispatchEvent(new CustomEvent(TOKEN_INVALID_EVENT));
let detail = "token rejected by cloud api";
try {
const payload = (await response.json()) as { detail?: unknown };
if (typeof payload.detail === "string") {
detail = payload.detail;
}
} catch {
// fall back to the default detail
}
throw new CloudApiError(response.status, detail);
}
if (!response.ok) {
let message = `${response.status} ${response.statusText}`;
try {
const payload = (await response.json()) as { detail?: unknown };
if (typeof payload.detail === "string") {
message = payload.detail;
} else if (payload.detail) {
message = JSON.stringify(payload.detail);
}
} catch {
message = await response.text().catch(() => message);
}
throw new CloudApiError(response.status, message);
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
}
export function listTasks(options?: {
status?: TaskStatus;
limit?: number;
offset?: number;
}): Promise<TaskListResponse> {
const params = new URLSearchParams();
if (options?.status) params.set("status", options.status);
params.set("limit", String(options?.limit ?? 50));
params.set("offset", String(options?.offset ?? 0));
const query = params.toString();
return request<TaskListResponse>(`/v1/tasks${query ? `?${query}` : ""}`);
}
export function getTaskAttempts(taskId: string): Promise<TaskAttempt[]> {
return request<TaskAttempt[]>(
`/v1/tasks/${encodeURIComponent(taskId)}/attempts`,
);
}
export function listDevices(): Promise<DeviceRecord[]> {
return request<DeviceRecord[]>("/v1/devices");
}
export function listHosts(): Promise<HostRecord[]> {
return request<HostRecord[]>("/v1/hosts");
}
export function listPlugins(): Promise<PluginRecord[]> {
return request<PluginRecord[]>("/v1/plugins");
}
export function registerPlugin(
payload: PluginRegistrationPayload,
): Promise<PluginRecord> {
return request<PluginRecord>("/v1/plugins", {
method: "POST",
body: JSON.stringify(payload),
});
}
+9
View File
@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_CLOUD_API_BASE_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from "vue";
import App from "./App.vue";
import "./style.css";
createApp(App).mount("#app");
+350
View File
@@ -0,0 +1,350 @@
:root {
--bg: #0f172a;
--bg-elev: #1e293b;
--bg-elev-2: #273449;
--border: #334155;
--text: #e2e8f0;
--text-muted: #94a3b8;
--text-dim: #64748b;
--accent: #38bdf8;
--accent-hover: #7dd3fc;
--danger: #f87171;
--danger-bg: #7f1d1d;
--success: #4ade80;
--warning: #fbbf24;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
color-scheme: dark;
}
* {
box-sizing: border-box;
}
html,
body,
#app {
height: 100%;
margin: 0;
}
body {
background: var(--bg);
color: var(--text);
font-size: 14px;
line-height: 1.5;
}
button {
font: inherit;
cursor: pointer;
background: var(--bg-elev-2);
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px 12px;
transition: background 0.15s ease;
}
button:hover:not(:disabled) {
background: var(--border);
}
button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
button.primary {
background: var(--accent);
color: #0b1220;
border-color: var(--accent);
}
button.primary:hover:not(:disabled) {
background: var(--accent-hover);
}
input,
select,
textarea {
font: inherit;
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px 10px;
}
input:focus,
select:focus,
textarea:focus {
outline: none;
border-color: var(--accent);
}
label {
color: var(--text-muted);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
a {
color: var(--accent);
}
.app-shell {
display: flex;
height: 100%;
}
.app-nav {
width: 220px;
background: var(--bg-elev);
border-right: 1px solid var(--border);
padding: 16px 12px;
display: flex;
flex-direction: column;
gap: 4px;
}
.app-nav h1 {
font-size: 14px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-muted);
margin: 0 0 12px;
}
.app-nav button {
text-align: left;
background: transparent;
border-color: transparent;
display: flex;
align-items: center;
gap: 8px;
}
.app-nav button:hover:not(:disabled) {
background: var(--bg-elev-2);
}
.app-nav button.active {
background: var(--bg-elev-2);
color: var(--accent);
border-color: var(--border);
}
.app-nav .spacer {
flex: 1;
}
.app-main {
flex: 1;
overflow: auto;
padding: 24px 32px;
}
.toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.toolbar h2 {
margin: 0;
font-size: 20px;
}
.panel {
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
padding: 8px 10px;
text-align: left;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
th {
color: var(--text-muted);
font-weight: 500;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
tr.row-selectable {
cursor: pointer;
}
tr.row-selectable:hover {
background: var(--bg-elev-2);
}
tr.row-selected {
background: var(--bg-elev-2);
}
.status-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
text-transform: lowercase;
}
.status-badge.queued,
.status-badge.assigned,
.status-badge.dispatched {
background: rgba(56, 189, 248, 0.18);
color: var(--accent);
}
.status-badge.done {
background: rgba(74, 222, 128, 0.18);
color: var(--success);
}
.status-badge.failed {
background: rgba(248, 113, 113, 0.18);
color: var(--danger);
}
.status-badge.unreachable {
background: rgba(248, 113, 113, 0.18);
color: var(--danger);
}
.status-badge.idle,
.status-badge.wired {
background: rgba(74, 222, 128, 0.18);
color: var(--success);
}
.status-badge.busy {
background: rgba(251, 191, 36, 0.18);
color: var(--warning);
}
.notice {
padding: 10px 12px;
border-radius: 6px;
background: var(--bg-elev-2);
border: 1px solid var(--border);
color: var(--text-muted);
}
.notice.error {
background: rgba(127, 29, 29, 0.4);
border-color: var(--danger);
color: var(--text);
}
.notice.success {
background: rgba(34, 197, 94, 0.18);
border-color: var(--success);
color: var(--text);
}
.token-screen {
max-width: 480px;
margin: 80px auto;
padding: 32px;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: 12px;
}
.token-screen h1 {
margin: 0 0 8px;
font-size: 24px;
}
.token-screen p {
color: var(--text-muted);
margin: 0 0 24px;
}
.token-screen label {
display: block;
margin-bottom: 6px;
}
.token-screen textarea {
width: 100%;
min-height: 88px;
resize: vertical;
font-family: ui-monospace, SFMono-Regular, "Cascadia Code", Consolas, monospace;
}
.token-screen .actions {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.form-grid label {
display: block;
margin-bottom: 4px;
}
.form-grid .field-full {
grid-column: 1 / -1;
}
.muted {
color: var(--text-muted);
}
.dim {
color: var(--text-dim);
font-size: 12px;
}
.attempt-result {
font-family: ui-monospace, SFMono-Regular, "Cascadia Code", Consolas, monospace;
font-size: 12px;
white-space: pre-wrap;
word-break: break-all;
}
.pagination {
display: flex;
align-items: center;
gap: 12px;
margin-top: 12px;
color: var(--text-muted);
}
.loader {
display: inline-block;
animation: spin 1.2s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
+70
View File
@@ -0,0 +1,70 @@
export type TaskStatus =
| "queued"
| "assigned"
| "dispatched"
| "done"
| "failed";
export interface TaskListItem {
id: string;
status: TaskStatus;
goal: string | null;
workflow_definition_id: string | null;
assigned_device_id: string | null;
assigned_host_id: string | null;
attempt_count: number;
failure_reason: string | null;
created_at: string;
}
export interface TaskListResponse {
items: TaskListItem[];
total: number;
limit: number;
offset: number;
}
export interface TaskAttempt {
task_id: string;
attempt: number;
lease_id: string;
host_id: string;
device_id: string;
status: string;
lease_expires_at: string;
created_at: string;
completed_at: string | null;
failure_reason: string | null;
terminal_result: Record<string, unknown> | null;
}
export interface DeviceRecord {
device_id: string;
host_id: string;
driver_type: string;
status: string;
capability_tags: string[];
}
export interface HostRecord {
host_id: string;
address: string | null;
last_seen_at: string;
}
export type PluginEntryPointKind = "driver" | "tool" | "skill";
export interface PluginRecord {
name: string;
version: string;
entry_point_kind: string;
target: string;
wired: boolean;
}
export interface PluginRegistrationPayload {
name: string;
version: string;
entry_point_kind: PluginEntryPointKind;
target: string;
}
+167
View File
@@ -0,0 +1,167 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { LoaderCircle, RefreshCw } from "@lucide/vue";
import { CloudApiError, listDevices, listHosts } from "../api";
import type { DeviceRecord, HostRecord } from "../types";
const loading = ref(false);
const errorMessage = ref("");
const devices = ref<DeviceRecord[]>([]);
const hosts = ref<HostRecord[]>([]);
const staleAfterSeconds = ref(120);
const staleHostIds = computed(() => {
const cutoff = Date.now() - staleAfterSeconds.value * 1000;
return new Set(
hosts.value
.filter((host) => Date.parse(host.last_seen_at) < cutoff)
.map((host) => host.host_id),
);
});
function hostIsStale(hostId: string): boolean {
return staleHostIds.value.has(hostId);
}
async function refresh() {
loading.value = true;
errorMessage.value = "";
try {
[devices.value, hosts.value] = await Promise.all([listDevices(), listHosts()]);
} catch (err) {
if (err instanceof CloudApiError) {
errorMessage.value = err.message;
} else if (err instanceof Error) {
errorMessage.value = err.message;
} else {
errorMessage.value = "failed to load device pool";
}
} finally {
loading.value = false;
}
}
function formatTime(value: string): string {
const parsed = Date.parse(value);
if (Number.isNaN(parsed)) return value;
const date = new Date(parsed);
const secondsAgo = Math.round((Date.now() - parsed) / 1000);
const relative =
secondsAgo < 60
? `${secondsAgo}s ago`
: `${Math.round(secondsAgo / 60)}m ago`;
return `${date.toLocaleString()} (${relative})`;
}
onMounted(refresh);
</script>
<template>
<div>
<div class="toolbar">
<h2>Device pool & host registry</h2>
<label>
Stale after (s)
<input
v-model.number="staleAfterSeconds"
type="number"
min="5"
step="5"
style="width: 80px"
/>
</label>
<button :disabled="loading" @click="refresh">
<RefreshCw :size="14" />
Refresh
</button>
<span v-if="loading" class="muted">
<LoaderCircle :size="14" class="loader" /> loading
</span>
</div>
<div v-if="errorMessage" class="notice error">{{ errorMessage }}</div>
<div class="panel">
<h3>Hosts</h3>
<table v-if="hosts.length">
<thead>
<tr>
<th>Host ID</th>
<th>Address</th>
<th>Last seen</th>
<th>State</th>
</tr>
</thead>
<tbody>
<tr v-for="host in hosts" :key="host.host_id">
<td>
<code>{{ host.host_id }}</code>
</td>
<td>{{ host.address || "—" }}</td>
<td class="dim">{{ formatTime(host.last_seen_at) }}</td>
<td>
<span
:class="
hostIsStale(host.host_id)
? 'status-badge unreachable'
: 'status-badge idle'
"
>
{{ hostIsStale(host.host_id) ? "stale" : "healthy" }}
</span>
</td>
</tr>
</tbody>
</table>
<div v-else class="muted">No hosts registered.</div>
</div>
<div class="panel">
<h3>Devices</h3>
<table v-if="devices.length">
<thead>
<tr>
<th>Device ID</th>
<th>Host</th>
<th>Driver</th>
<th>Status</th>
<th>Capabilities</th>
</tr>
</thead>
<tbody>
<tr
v-for="device in devices"
:key="`${device.host_id}/${device.device_id}`"
>
<td><code>{{ device.device_id }}</code></td>
<td>
<code>{{ device.host_id }}</code>
<span v-if="hostIsStale(device.host_id)" class="status-badge unreachable">
host stale
</span>
</td>
<td>{{ device.driver_type }}</td>
<td>
<span
:class="
hostIsStale(device.host_id)
? 'status-badge unreachable'
: `status-badge ${device.status}`
"
>
{{ hostIsStale(device.host_id) ? "unreachable" : device.status }}
</span>
</td>
<td>
<span v-if="device.capability_tags.length">
{{ device.capability_tags.join(", ") }}
</span>
<span v-else class="dim"></span>
</td>
</tr>
</tbody>
</table>
<div v-else class="muted">No devices pooled.</div>
</div>
</div>
</template>
+191
View File
@@ -0,0 +1,191 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue";
import { LoaderCircle, Plus, RefreshCw } from "@lucide/vue";
import { CloudApiError, listPlugins, registerPlugin } from "../api";
import type {
PluginEntryPointKind,
PluginRecord,
} from "../types";
const loading = ref(false);
const errorMessage = ref("");
const plugins = ref<PluginRecord[]>([]);
const showForm = ref(false);
const formError = ref("");
const formSuccess = ref("");
const submitting = ref(false);
const ENTRY_POINT_KINDS: PluginEntryPointKind[] = ["driver", "tool", "skill"];
const form = reactive({
name: "",
version: "",
entry_point_kind: "tool" as PluginEntryPointKind,
target: "",
});
function resetForm() {
form.name = "";
form.version = "";
form.entry_point_kind = "tool";
form.target = "";
formError.value = "";
formSuccess.value = "";
}
async function refresh() {
loading.value = true;
errorMessage.value = "";
try {
plugins.value = await listPlugins();
} catch (err) {
describeError(err, "failed to load plugin registry");
} finally {
loading.value = false;
}
}
function describeError(err: unknown, fallback: string) {
if (err instanceof CloudApiError) {
errorMessage.value = err.message;
} else if (err instanceof Error) {
errorMessage.value = err.message;
} else {
errorMessage.value = fallback;
}
}
async function submit() {
formError.value = "";
formSuccess.value = "";
if (!form.name.trim() || !form.version.trim() || !form.target.trim()) {
formError.value = "name, version, and target are required";
return;
}
submitting.value = true;
try {
const created = await registerPlugin({
name: form.name.trim(),
version: form.version.trim(),
entry_point_kind: form.entry_point_kind,
target: form.target.trim(),
});
formSuccess.value = `registered ${created.name}@${created.version}`;
resetForm();
showForm.value = false;
await refresh();
} catch (err) {
if (err instanceof CloudApiError) {
formError.value = err.message;
} else if (err instanceof Error) {
formError.value = err.message;
} else {
formError.value = "registration failed";
}
} finally {
submitting.value = false;
}
}
onMounted(refresh);
</script>
<template>
<div>
<div class="toolbar">
<h2>Plugin registry</h2>
<button :disabled="loading" @click="refresh">
<RefreshCw :size="14" />
Refresh
</button>
<button class="primary" @click="showForm = !showForm">
<Plus :size="14" />
{{ showForm ? "Close form" : "Register plugin" }}
</button>
<span v-if="loading" class="muted">
<LoaderCircle :size="14" class="loader" /> loading
</span>
</div>
<div v-if="errorMessage" class="notice error">{{ errorMessage }}</div>
<div v-if="formSuccess" class="notice success">{{ formSuccess }}</div>
<div class="panel" v-if="showForm">
<h3>Register a plugin</h3>
<p class="dim">
The cloud api requires the <code>plugins:admin</code> scope for this
call. Without it the api will respond with <code>403</code>, which the
form surfaces below.
</p>
<form @submit.prevent="submit">
<div class="form-grid">
<div>
<label for="plugin-name">Name</label>
<input id="plugin-name" v-model="form.name" autocomplete="off" />
</div>
<div>
<label for="plugin-version">Version</label>
<input id="plugin-version" v-model="form.version" autocomplete="off" />
</div>
<div>
<label for="plugin-kind">Entry point kind</label>
<select id="plugin-kind" v-model="form.entry_point_kind">
<option v-for="kind in ENTRY_POINT_KINDS" :key="kind" :value="kind">
{{ kind }}
</option>
</select>
</div>
<div>
<label for="plugin-target">Target</label>
<input
id="plugin-target"
v-model="form.target"
placeholder="module.path:AttributeName"
autocomplete="off"
/>
</div>
</div>
<div v-if="formError" class="notice error" style="margin-top: 12px">
{{ formError }}
</div>
<div class="toolbar" style="margin-top: 12px">
<button type="submit" class="primary" :disabled="submitting">
Submit
</button>
<button type="button" @click="resetForm">Clear</button>
</div>
</form>
</div>
<div class="panel">
<table v-if="plugins.length">
<thead>
<tr>
<th>Name</th>
<th>Version</th>
<th>Kind</th>
<th>Target</th>
<th>Wired</th>
</tr>
</thead>
<tbody>
<tr v-for="plugin in plugins" :key="plugin.name">
<td><code>{{ plugin.name }}</code></td>
<td>{{ plugin.version }}</td>
<td>
<span class="status-badge queued">{{ plugin.entry_point_kind }}</span>
</td>
<td class="dim">{{ plugin.target }}</td>
<td>
<span :class="plugin.wired ? 'status-badge wired' : 'status-badge failed'">
{{ plugin.wired ? "wired" : "not wired" }}
</span>
</td>
</tr>
</tbody>
</table>
<div v-else class="muted">No plugins registered.</div>
</div>
</div>
</template>
+305
View File
@@ -0,0 +1,305 @@
<script setup lang="ts">
import { onMounted, ref, watch } from "vue";
import { LoaderCircle, RefreshCw } from "@lucide/vue";
import {
CloudApiError,
getTaskAttempts,
listTasks,
} from "../api";
import type {
TaskAttempt,
TaskListItem,
TaskListResponse,
TaskStatus,
} from "../types";
const STATUSES: TaskStatus[] = [
"queued",
"assigned",
"dispatched",
"done",
"failed",
];
const statusFilter = ref<TaskStatus | "">("");
const pageSize = ref(50);
const offset = ref(0);
const loading = ref(false);
const errorMessage = ref("");
const result = ref<TaskListResponse | null>(null);
const selectedTask = ref<TaskListItem | null>(null);
const attempts = ref<TaskAttempt[]>([]);
const attemptsLoading = ref(false);
const attemptsError = ref("");
async function refresh() {
loading.value = true;
errorMessage.value = "";
try {
result.value = await listTasks({
status: statusFilter.value === "" ? undefined : statusFilter.value,
limit: pageSize.value,
offset: offset.value,
});
if (selectedTask.value) {
const stillPresent = result.value.items.find(
(item) => item.id === selectedTask.value?.id,
);
if (!stillPresent) {
selectedTask.value = null;
attempts.value = [];
}
}
} catch (err) {
handleError(err, "failed to load tasks");
} finally {
loading.value = false;
}
}
async function selectTask(task: TaskListItem) {
selectedTask.value = task;
attempts.value = [];
attemptsError.value = "";
attemptsLoading.value = true;
try {
attempts.value = await getTaskAttempts(task.id);
} catch (err) {
if (err instanceof CloudApiError && err.status === 404) {
// Task was deleted between list and detail load.
selectedTask.value = null;
await refresh();
} else {
handleError(err, "failed to load task attempts");
attemptsError.value = errorMessage.value;
errorMessage.value = "";
}
} finally {
attemptsLoading.value = false;
}
}
function handleError(err: unknown, fallback: string) {
if (err instanceof CloudApiError) {
errorMessage.value = err.message;
} else if (err instanceof Error) {
errorMessage.value = err.message;
} else {
errorMessage.value = fallback;
}
}
function goPrevPage() {
offset.value = Math.max(0, offset.value - pageSize.value);
}
function goNextPage() {
if (!result.value) return;
if (offset.value + pageSize.value >= result.value.total) return;
offset.value = offset.value + pageSize.value;
}
function clearSelection() {
selectedTask.value = null;
attempts.value = [];
}
watch(statusFilter, () => {
offset.value = 0;
refresh();
});
watch(pageSize, () => {
offset.value = 0;
refresh();
});
watch(offset, refresh);
onMounted(refresh);
function formatTime(value: string | null): string {
if (!value) return "—";
try {
return new Date(value).toLocaleString();
} catch {
return value;
}
}
function attemptOutcomeClass(status: string): string {
if (status === "done") return "status-badge done";
if (status === "failed" || status === "expired") return "status-badge failed";
return "status-badge queued";
}
function formatTerminalResult(attempt: TaskAttempt): string {
if (!attempt.terminal_result) return "—";
try {
return JSON.stringify(attempt.terminal_result, null, 2);
} catch {
return String(attempt.terminal_result);
}
}
</script>
<template>
<div>
<div class="toolbar">
<h2>Tasks</h2>
<label>
Status
<select v-model="statusFilter">
<option value="">any</option>
<option v-for="s in STATUSES" :key="s" :value="s">{{ s }}</option>
</select>
</label>
<label>
Page size
<select v-model.number="pageSize">
<option :value="10">10</option>
<option :value="25">25</option>
<option :value="50">50</option>
<option :value="100">100</option>
</select>
</label>
<button :disabled="loading" @click="refresh">
<RefreshCw :size="14" />
Refresh
</button>
<span v-if="loading" class="muted">
<LoaderCircle :size="14" class="loader" /> loading
</span>
</div>
<div v-if="errorMessage" class="notice error">{{ errorMessage }}</div>
<div class="panel" v-if="!selectedTask">
<table v-if="result && result.items.length">
<thead>
<tr>
<th>ID</th>
<th>Status</th>
<th>Goal / Workflow</th>
<th>Assignment</th>
<th>Attempts</th>
<th>Created</th>
</tr>
</thead>
<tbody>
<tr
v-for="task in result.items"
:key="task.id"
class="row-selectable"
@click="selectTask(task)"
>
<td>
<code>{{ task.id.slice(0, 8) }}</code>
</td>
<td>
<span class="status-badge" :class="task.status">{{ task.status }}</span>
</td>
<td>
<div v-if="task.goal">{{ task.goal }}</div>
<div v-else-if="task.workflow_definition_id" class="muted">
wf: {{ task.workflow_definition_id }}
</div>
<div v-else class="dim"></div>
<div v-if="task.failure_reason" class="dim">
{{ task.failure_reason }}
</div>
</td>
<td>
<div v-if="task.assigned_device_id">
{{ task.assigned_device_id }}
<span class="dim">on {{ task.assigned_host_id }}</span>
</div>
<div v-else class="dim">unassigned</div>
</td>
<td>{{ task.attempt_count }}</td>
<td class="dim">{{ formatTime(task.created_at) }}</td>
</tr>
</tbody>
</table>
<div v-else-if="result" class="muted">No tasks match the current filter.</div>
<div class="pagination" v-if="result">
<span>
showing
{{ result.offset + 1 }}{{
Math.min(result.offset + result.items.length, result.total)
}}
of {{ result.total }}
</span>
<button :disabled="offset === 0" @click="goPrevPage">Prev</button>
<button
:disabled="offset + pageSize >= result.total"
@click="goNextPage"
>
Next
</button>
</div>
</div>
<div class="panel" v-else>
<div class="toolbar">
<h2>
Task <code>{{ selectedTask.id.slice(0, 8) }}</code>
</h2>
<button @click="clearSelection">Back to list</button>
</div>
<p class="muted">
Status: <span class="status-badge" :class="selectedTask.status">{{ selectedTask.status }}</span>
· Attempts: {{ selectedTask.attempt_count }}
</p>
<p v-if="selectedTask.goal">
<strong>Goal:</strong> {{ selectedTask.goal }}
</p>
<p v-if="selectedTask.workflow_definition_id">
<strong>Workflow:</strong>
<code>{{ selectedTask.workflow_definition_id }}</code>
</p>
<p v-if="selectedTask.failure_reason">
<strong class="text-danger">Failure reason:</strong>
{{ selectedTask.failure_reason }}
</p>
<h3>Attempt history</h3>
<div v-if="attemptsLoading" class="muted">loading attempts</div>
<div v-else-if="attemptsError" class="notice error">{{ attemptsError }}</div>
<table v-else-if="attempts.length">
<thead>
<tr>
<th>#</th>
<th>Status</th>
<th>Host / Device</th>
<th>Lease expires</th>
<th>Created</th>
<th>Completed</th>
<th>Failure</th>
<th>Terminal result</th>
</tr>
</thead>
<tbody>
<tr v-for="attempt in attempts" :key="attempt.attempt">
<td>{{ attempt.attempt }}</td>
<td>
<span :class="attemptOutcomeClass(attempt.status)">{{ attempt.status }}</span>
</td>
<td>
{{ attempt.host_id }}
<span class="dim">/ {{ attempt.device_id }}</span>
</td>
<td class="dim">{{ formatTime(attempt.lease_expires_at) }}</td>
<td class="dim">{{ formatTime(attempt.created_at) }}</td>
<td class="dim">{{ formatTime(attempt.completed_at) }}</td>
<td v-if="attempt.failure_reason">{{ attempt.failure_reason }}</td>
<td v-else class="dim"></td>
<td>
<pre class="attempt-result">{{ formatTerminalResult(attempt) }}</pre>
</td>
</tr>
</tbody>
</table>
<div v-else class="muted">No attempts recorded for this task yet.</div>
</div>
</div>
</template>
+51
View File
@@ -0,0 +1,51 @@
<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>
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.vue", "src/**/*.d.ts"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
+6
View File
@@ -0,0 +1,6 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [vue()],
});
+7 -3
View File
@@ -1,6 +1,6 @@
services:
postgres:
image: postgres:18
image: registry.jerryyan.net/library/postgres:18
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
@@ -15,7 +15,7 @@ services:
restart: unless-stopped
cloud-api:
image: ${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG:-latest}
image: git.jerryyan.net/q792602257/agentic-mobile-control:${IMAGE_TAG:-latest}
command:
- sh
- -c
@@ -27,6 +27,7 @@ services:
CLOUD_DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
CLOUD_PUBLIC_CREDENTIALS_JSON: ${CLOUD_PUBLIC_CREDENTIALS_JSON}
CLOUD_HOST_CREDENTIALS_JSON: ${CLOUD_HOST_CREDENTIALS_JSON}
CLOUD_ENROLLMENT_TOKENS_JSON: ${CLOUD_ENROLLMENT_TOKENS_JSON:-[]}
CLOUD_SCHEDULER_INTERVAL_SECONDS: ${CLOUD_SCHEDULER_INTERVAL_SECONDS:-1}
CLOUD_LEASE_REAPER_INTERVAL_SECONDS: ${CLOUD_LEASE_REAPER_INTERVAL_SECONDS:-5}
CLOUD_LEASE_DURATION_SECONDS: ${CLOUD_LEASE_DURATION_SECONDS:-60}
@@ -44,12 +45,15 @@ services:
restart: unless-stopped
host-agent:
image: ${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG:-latest}
image: git.jerryyan.net/q792602257/agentic-mobile-control:${IMAGE_TAG:-latest}
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_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}
+4
View File
@@ -28,6 +28,7 @@ services:
CLOUD_DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
CLOUD_PUBLIC_CREDENTIALS_JSON: ${CLOUD_PUBLIC_CREDENTIALS_JSON}
CLOUD_HOST_CREDENTIALS_JSON: ${CLOUD_HOST_CREDENTIALS_JSON}
CLOUD_ENROLLMENT_TOKENS_JSON: ${CLOUD_ENROLLMENT_TOKENS_JSON:-[]}
CLOUD_SCHEDULER_INTERVAL_SECONDS: ${CLOUD_SCHEDULER_INTERVAL_SECONDS:-1}
CLOUD_LEASE_REAPER_INTERVAL_SECONDS: ${CLOUD_LEASE_REAPER_INTERVAL_SECONDS:-5}
CLOUD_LEASE_DURATION_SECONDS: ${CLOUD_LEASE_DURATION_SECONDS:-60}
@@ -52,6 +53,9 @@ services:
HOST_AGENT_CONTROL_PLANE_URL: http://cloud-api:8001
HOST_AGENT_HOST_ID: ${HOST_AGENT_HOST_ID}
HOST_AGENT_TOKEN: ${HOST_AGENT_TOKEN}
HOST_AGENT_ENROLLMENT_TOKEN: ${HOST_AGENT_ENROLLMENT_TOKEN:-}
HOST_AGENT_IDENTITY_PATH: ${HOST_AGENT_IDENTITY_PATH:-/app/tasks/host_identity.json}
HOST_AGENT_DISPLAY_NAME: ${HOST_AGENT_DISPLAY_NAME:-}
HOST_AGENT_HEARTBEAT_INTERVAL_SECONDS: ${HOST_AGENT_HEARTBEAT_INTERVAL_SECONDS:-30}
HOST_AGENT_POLL_TIMEOUT_SECONDS: ${HOST_AGENT_POLL_TIMEOUT_SECONDS:-20}
HOST_AGENT_RETRY_BACKOFF_SECONDS: ${HOST_AGENT_RETRY_BACKOFF_SECONDS:-1}
+150 -3
View File
@@ -41,6 +41,49 @@ defaults to `./tasks`.
The Host Agent only initiates outbound HTTP requests. It does not expose an
inbound port.
## Managed Edge Enrollment
New edge installations do not need a pre-coordinated Host or device ID. The
Cloud API accepts configured one-time enrollment credentials:
```powershell
$env:CLOUD_ENROLLMENT_TOKENS_JSON = '[{"principal_id":"edge-installer","token":"replace-with-a-long-random-one-time-token"}]'
```
On the edge Host, omit `HOST_AGENT_HOST_ID` and `HOST_AGENT_TOKEN` and provide
the enrollment token only for the first successful enrollment:
```bash
export HOST_AGENT_CONTROL_PLANE_URL="https://cloud.example.com"
export HOST_AGENT_ENROLLMENT_TOKEN="replace-with-a-long-random-one-time-token"
export HOST_AGENT_IDENTITY_PATH="tasks/host_identity.json"
export HOST_AGENT_DISPLAY_NAME="Edge Mac 01"
uv run --package device-host-agent device-host-agent
```
Before its first request the Host Agent creates `HOST_AGENT_IDENTITY_PATH` with
an instance identifier and long-lived random Host secret. The cloud consumes
the enrollment token, assigns `host_id`, stores only credential digests, and
returns the assigned ID. The Host Agent then enrolls each record from
`tasks/device_config.sqlite3`, stores its cloud-assigned `device_id` in that
database, connects the resulting devices, and starts heartbeat/claim loops.
Keep the identity file and device configuration database on persistent edge
storage with permissions limited to the service account. The identity file is
a bearer secret: do not put it in an image, repository, log, or general backup.
After successful enrollment, remove `HOST_AGENT_ENROLLMENT_TOKEN` from the edge
environment. An intact identity file is sufficient for restart; if only a
device mapping is lost, device enrollment reconstructs the same cloud ID.
Enrollment tokens are one-time even when they remain in Cloud API environment
configuration: their consumed digest is stored in the database. Reusing a token
for another edge instance returns a conflict. Create a distinct token for every
edge installation.
Explicit `HOST_AGENT_HOST_ID` plus `HOST_AGENT_TOKEN` takes precedence and keeps
the previous legacy behavior, including locally selected device IDs. This is
the rollback and staged-migration path for existing deployments.
## PostgreSQL Deployment
Start from `.env.example`, replace every `change-me-*` value, and keep the
@@ -106,11 +149,108 @@ only the scopes required by each integration:
include exactly one `host_id`; its token is valid only for heartbeat, claim,
renewal, and result operations for that host.
`CLOUD_ENROLLMENT_TOKENS_JSON` contains bootstrap principals with only
`principal_id` and `token`. These credentials cannot submit tasks, read the
pool, or operate as a Host; they can only create one durable Host binding.
Use high-entropy values generated by the deployment secret manager.
Do not place bearer tokens in command history, image layers, Compose files, or
logs. Use environment injection or the deployment platform's secret manager.
Rotate a token by deploying the updated Cloud API credential set and Host Agent
configuration together.
Dynamically enrolled Host credentials are stored as digests in the cloud
database. This release exposes repository-level revocation rather than a public
administration endpoint. An operator with database deployment access can revoke
a Host without deleting its task history:
```bash
export HOST_ID="host-..."
uv run --package device-cloud-platform python - <<'PY'
import os
from cloud.database import CloudDatabase
from core.models import utc_now
database = CloudDatabase(os.environ["CLOUD_DATABASE_URL"], create_schema=False)
try:
changed = database.repository.revoke_enrolled_host(
os.environ["HOST_ID"],
revoked_at=utc_now(),
)
print("revoked" if changed else "not an enrolled host")
finally:
database.close()
PY
```
## Cloud Console (Web UI)
The repository ships an independent Vue 3 + Vite SPA at `cloud-console/` that
renders the task queue/history, device pool, host registry, and plugin
registry, and exposes the existing plugin-registration action. It authenticates
the same way `CloudClient` does: by attaching a pre-issued bearer token to
every request. There is no login or session system.
### Provision an operator bearer token
Add a `CLOUD_PUBLIC_CREDENTIALS_JSON` entry whose scopes cover what the
console operators need to do. The least-privilege set for read-only dashboards
is `tasks:read`, `pool:read`, and `plugins:read`. Add `tasks:submit` only if
operators should submit ad-hoc tasks from the same tab, and `plugins:admin`
only if operators should register plugins:
```json
[
{
"principal_id": "console-operator",
"token": "replace-with-a-long-random-opaque-token",
"scopes": ["tasks:read", "pool:read", "plugins:read", "plugins:admin"]
}
]
```
Rotate the token the same way as any other credential entry: deploy the
updated Cloud API credential set and instruct operators to paste the new token
into the console. The console keeps the token only in browser `sessionStorage`
for that tab; closing the tab discards it.
### Configure the CORS allow-list
The Cloud API has no CORS middleware by default. Before a browser can call it
cross-origin, set `CLOUD_CONSOLE_CORS_ORIGINS` to a comma-separated allow-list
that includes the exact origin (scheme + host + port, no trailing slash) the
operator's browser will load the console from:
```bash
# Allow a local Vite dev server
export CLOUD_CONSOLE_CORS_ORIGINS="http://127.0.0.1:5173"
# Or a deployed origin
export CLOUD_CONSOLE_CORS_ORIGINS="https://console.example.com"
```
Restart the Cloud API after changing this env. The middleware is added only
when the allow-list is non-empty — existing deployments see no behavior change
until an operator opts in. Blanket `allow_origins=["*"]` is intentionally not
supported because every console request carries a bearer token.
### Run the console
```bash
cd cloud-console
cp .env.example .env.local
# Edit .env.local if your Cloud API is not at http://127.0.0.1:8001
npm install
npm run dev
```
Vite prints a local URL (default `http://127.0.0.1:5173`). That exact origin
must be in `CLOUD_CONSOLE_CORS_ORIGINS` on the Cloud API. Open the dev URL,
paste the operator token, and the dashboards become available.
For a production build, run `npm run build` and serve the resulting `dist/`
behind any static file server or CDN, with `VITE_CLOUD_API_BASE_URL` baked in
at build time. The deployed origin must be in `CLOUD_CONSOLE_CORS_ORIGINS`.
## Runtime AI Planner
The Host Agent reuses the local Runtime planner. AI planning is disabled by
@@ -167,17 +307,24 @@ For rollback:
1. Stop all Host Agents and the Cloud API.
2. Back up PostgreSQL or the SQLite database file.
3. If the previous application version cannot use the current schema, run the
3. Before rolling back to a release without enrollment support, provision
temporary static Host credentials for every managed edge that must continue
operating. Stop those Host Agents and set their explicit Host ID/token.
4. If the previous application version cannot use the current schema, run the
tested downgrade while no application process is connected:
```bash
uv run alembic -c packages/cloud-platform/cloud/migrations/alembic.ini downgrade -1
```
4. Restore the previous application image or checkout and start the Cloud API.
5. Verify `/health/ready`, then restart Host Agents with credentials compatible
5. Restore the previous application image or checkout and start the Cloud API.
6. Verify `/health/ready`, then restart Host Agents with credentials compatible
with the restored Cloud API.
Downgrading revision 0002 removes dynamic credential bindings and durable
device enrollment mappings. It retains the revision-0001 Host heartbeat rows,
pooled devices, queued tasks, attempts, and plugins.
Do not remove the PostgreSQL volume during an application rollback. Queued and
attempt history are durable database state and should remain available to the
restored or forward-deployed control plane.
+58 -4
View File
@@ -338,7 +338,60 @@ Console 默认连接 `http://127.0.0.1:8000`。已由上面启动脚本连接的
`iphone-1` 会出现在设备列表中。不要在 Console 中重复登记同一台设备;当前登记
操作只写入配置,不会自动 connect。
## 9. 多设备与端口
## 9. 启动云端受管 Host Agent
完成 Appium/WDA 真机验证后,可以把这台 Mac 作为只发起出站连接的边缘 Host。
先把设备连接参数写入本地配置;这里的 `device_id` 只是 Mac 内部引用,不需要与
云端协调,云端会在首次启动时下发正式 ID:
```bash
uv run --package device-agent-runtime python - <<'PY'
import os
from storage.device_config import DeviceConfigStore
DeviceConfigStore("tasks/device_config.sqlite3").add(
device_id="local-iphone-1",
name="Edge iPhone",
driver_type="wda",
connection_info={
"server_url": "http://127.0.0.1:4723",
"device_name": "iPhone",
"udid": os.environ["DEVICE_UDID"],
"wda_local_port": 8100,
"xcodeOrgId": os.environ["APPLE_TEAM_ID"],
"xcodeSigningId": "Apple Development",
"updatedWDABundleId": os.environ["WDA_BUNDLE_ID"],
},
)
PY
```
从云端管理员获取一枚未使用的一次性 enrollment token,然后启动:
```bash
export HOST_AGENT_CONTROL_PLANE_URL="https://cloud.example.com"
export HOST_AGENT_ENROLLMENT_TOKEN="<one-time enrollment token>"
export HOST_AGENT_IDENTITY_PATH="tasks/host_identity.json"
export HOST_AGENT_DISPLAY_NAME="Edge Mac 01"
export AI_PLANNER_ENABLED="true"
export AI_PLANNER_PROVIDER="anthropic"
export ANTHROPIC_API_KEY="<secret>"
uv run --package device-host-agent device-host-agent
```
首次启动顺序为:持久化候选 Host secret、向云端换取 `host_id`、为每个本地设备
换取 `device_id`、保存映射、连接 WDA、发送 heartbeat、开始 long-poll 领取任务。
Host Agent 不监听入站端口。成功后可以从边缘环境移除 enrollment token,但必须
保留并保护 `tasks/host_identity.json``tasks/device_config.sqlite3`;前者等同于
Host bearer credential。
需要回滚到静态模式时,停止 Host Agent,由云端管理员配置匹配的静态 Host
credential,然后显式设置 `HOST_AGENT_HOST_ID``HOST_AGENT_TOKEN`。这两个变量
同时存在时优先于 enrollment state。
## 10. 多设备与端口
同时连接多台 iPhone 时,每台设备至少需要:
@@ -364,7 +417,7 @@ Console 默认连接 `http://127.0.0.1:8000`。已由上面启动脚本连接的
`webDriverAgentUrl`。常规单机使用优先让 Appium 管理 WDA,不要一开始就引入
`iproxy` 或手工 WDA 生命周期。
## 10. 常见故障
## 11. 常见故障
### Appium 返回 `device offline`
@@ -420,7 +473,7 @@ Appium server 默认使用 4723;WDA 通常使用 8100。多设备必须为每
input、launch 和 UI tree 验证基础控制,再单独处理 PaddleOCR/PaddlePaddle 的 macOS
wheel 与 Apple Silicon 兼容性。
## 11. 完成检查表
## 12. 完成检查表
- [ ] Xcode 能看到已解锁的 iPhone。
- [ ] iPhone 已信任 Mac,并启用 Developer Mode。
@@ -433,8 +486,9 @@ wheel 与 Apple Silicon 兼容性。
- [ ] 实机 integration test 通过。
- [ ] Runtime API 返回 `iphone-1`,并能执行 screenshot/tap/launch。
- [ ] 如需 OCR,另行确认 PaddleOCR 在当前 Mac/Python 架构下可运行。
- [ ] 云端受管部署已保存 Host identity,并能在 `/v1/hosts``/v1/devices` 中看到。
## 12. 后续代码改进建议
## 13. 后续代码改进建议
为了让后续执行不再依赖内联 Python 启动脚本,建议另开变更实现:
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-13
+59
View File
@@ -0,0 +1,59 @@
## Context
`driver/base.py::Driver` is the driver-independent ABC (connect/disconnect/screenshot/tap/swipe/input/launch/terminate/tree/home/lock/unlock). `driver/wda_driver.py::WDADriver` is the only implementation today: it wraps the Appium Python Client's `XCUITestOptions`, builds a `webdriver.Remote` session against an Appium server, and wraps every operation's exceptions into `core.errors.DriverError`/`DeviceOfflineError`. `driver/registry.py::SUPPORTED_DRIVER_TYPES` maps a `driver_type` string (today only `"wda"`) to a builder that strips out the driver's own config fields from a `connection_info` dict and routes the rest into `extra_capabilities`.
`openspec/specs/driver-registry/spec.md` already documents this mechanism as driver-type-agnostic: "Adding a driver type requires no changes outside the driver layer." Grepping `api/console.py` and `api/rest.py` confirms neither contains a `"wda"` literal — the API layer resolves `driver_type` purely through the registry, so this change is expected to be additive to `driver/` alone.
This is exactly the case the project's Hexagonal + DDD governance decision calls out: `core`/`driver`/`device`/`tools` must stay free of LLM/HTTP-framework/MCP dependencies, and every new driver's design must state how it preserves that boundary. `docs/MACOS_IPHONE_SETUP.md` §1 already documents Android as the known, not-yet-registered gap this change closes.
## Goals / Non-Goals
**Goals:**
- Add a second real `Driver` implementation (`AndroidDriver`, Appium UiAutomator2) proving the registry extension point generalizes beyond WDA.
- Keep the change additive: no edits to `api/`, `device/`, `tools/`, or `runtime/`.
- Bring the new driver's test coverage above the existing WDA bar (mocked unit tests, not just a hardware-gated integration test).
**Non-Goals:**
- A full Android SDK/adb/real-device setup guide (`docs/ANDROID_SETUP.md` or equivalent) — deferred to a follow-up change once the driver can be validated against real hardware (user-confirmed scope decision).
- Espresso driver support — UiAutomator2 only, mirroring WDA's XCUITest-only scope.
- Renaming `APEX_WDA_*` env vars — an unrelated, already-documented follow-up item in `docs/MACOS_IPHONE_SETUP.md` §12.
- Any change to `Driver`'s abstract interface — the existing method set is sufficient; Android does not need new capabilities the ABC doesn't already express.
## Decisions
- **Registry key is `"uiautomator2"`, not `"android"`.** The existing key `"wda"` names the automation *backend* (WebDriverAgent), not the platform (`"ios"`). For the two supported driver types to stay consistent, Android's key should likewise name its backend — Appium's UiAutomator2 driver — not its platform. Rejected alternative: `"android"`, which would break that symmetry and would also be ambiguous if Espresso support is ever added later (both would be "android").
- **`AndroidDriverConfig` mirrors `WDADriverConfig` field-for-field where an Android equivalent exists**: `server_url` (same default `http://127.0.0.1:4723` — one Appium server can host sessions for both platforms), `platform_name="Android"`, `automation_name="UiAutomator2"`, `device_name`, `udid` (adb serial, selects among multiple connected devices), `no_reset`, `extra_capabilities`. `wda_local_port` (WDA's per-session port-isolation capability for parallel devices) has a direct UiAutomator2 analog — a system-port capability serving the same purpose — carried over as `system_port`. Rejected alternative: a from-scratch config shape — rejected because the parity makes both drivers predictable to configure from the same `connection_info` dict shape the registry already handles generically.
- **Same error-wrapping pattern as `WDADriver`**: every method's Appium/network exception is caught and re-raised as the existing `DriverError`, with `DeviceOfflineError` reserved for connect failures and pre-connect calls (via the same `_require_client()` guard pattern). Rejected alternative: introducing Android-specific error types — rejected because `core/errors.py`'s existing hierarchy is already driver-agnostic and callers above the driver layer must not need to know which concrete driver raised.
- **Appium UiAutomator2 mobile-command names/parameters for gesture-based methods (`tap`, `swipe`, `home`), confirmed via research against the official `appium-uiautomator2-driver` docs (2026-07)**: WDA's `tap`/`swipe`/`home` use WDA-specific `mobile:` command names (`mobile: tap`, `mobile: dragFromToForDuration`, `mobile: pressButton`) that do not carry over verbatim to UiAutomator2. The confirmed Android equivalents:
- `tap(x, y)``execute_script("mobile: clickGesture", {"x": x, "y": y})`. The driver's own docs recommend this over any legacy tap call as a workaround for native-tap failures, so it is also the more robust choice, not just the closest analog.
- `swipe(start_x, start_y, end_x, end_y, duration_ms)``execute_script("mobile: dragGesture", {"startX": start_x, "startY": start_y, "endX": end_x, "endY": end_y, "speed": speed})`. Unlike WDA's `dragFromToForDuration`, `dragGesture` takes a `speed` in pixels/second instead of a duration, so the implementation must convert: `speed = distance / (duration_ms / 1000)`, guarding against a zero/near-zero distance (fall back to the driver's default speed rather than dividing by zero). `mobile: swipeGesture` was considered and rejected — it takes a bounding-area + direction + percent shape, not a coordinate pair, so it does not match `Driver.swipe`'s signature.
- `home()``execute_script("mobile: pressKey", {"keycode": 3})` (Android `KeyEvent.KEYCODE_HOME`).
- The parallel-session port-isolation capability is confirmed as `appium:systemPort` (maps directly to `AndroidDriverConfig.system_port`), documented by the driver as "recommended for parallel tests" — the direct analog of WDA's `wdaLocalPort`.
- `screenshot`/`tree`/`input`/`launch`/`terminate`/`lock`/`unlock` map to the same cross-platform Selenium/Appium client methods WDA already uses (`get_screenshot_as_png`, `page_source`, `switch_to.active_element.send_keys`, `activate_app`, `terminate_app`, `lock`, `unlock`) and needed no research.
- Implementation should still sanity-check these against whatever `appium-uiautomator2-driver` version is actually resolved in `.venv` at coding time (docs reflect the driver's current released behavior as of this research, not a pinned version in this repo).
- **Appium 3 compatibility, confirmed via research (released 2025-08-07, latest 3.5.2 as of this research)**: Appium 3 is a deliberately small breaking-change release (Node.js/npm minimum version bump, mandatory feature-flag scope prefixes for `--allow-insecure`, `GET /sessions` moved to `GET /appium/sessions` behind a feature flag, full JSONWP removal in favor of W3C-only parameters, driver-owned file upload handling). None of these affect this change: `AndroidDriver` (like `WDADriver`) builds sessions purely through W3C `Options` classes, never relies on session discovery, requests no insecure feature flags, and does no file upload. `Appium-Python-Client>=5.1.1` (already pinned in root `pyproject.toml`) has no reported incompatibility with Appium 3 servers. No version pin changes are needed.
- **`driver-registry` spec gets a new scenario, not a new capability file.** No `wda-driver` capability spec exists today — the registry mechanism is spec'd once, generically, and individual driver behavior is not separately spec'd. Adding an `android-driver` capability would break that precedent for no benefit; instead, `driver-registry`'s existing "Building a factory for a known driver type" requirement gets a second scenario for `driver_type="uiautomator2"`, mirroring the existing `"wda"` scenario, so the spec's example coverage stays symmetric across both real driver types.
- **Test coverage exceeds current WDA parity on purpose.** `WDADriver` today has zero mocked unit tests — only `tests/test_wda_integration.py`, hardware-gated and skipped without `APEX_WDA_*` env vars. For `AndroidDriver`, add mocked unit tests (mock `appium.webdriver.Remote`) covering connect failure → `DeviceOfflineError`, pre-connect calls → `DeviceOfflineError`, and operation exceptions → `DriverError`, in addition to an equivalent hardware-gated `tests/test_android_integration.py`. This is a deliberate quality bar increase, not scope creep — it costs nothing extra in production code and closes a gap the WDA driver has always had.
- **No new setup documentation in this change.** Writing an accurate, detailed Android SDK/adb/real-device guide (parallel to `docs/MACOS_IPHONE_SETUP.md`'s 12 sections) without access to real hardware to validate each step would mean inventing untested instructions — the existing iOS doc reads as having been validated against a real device. `docs/MACOS_IPHONE_SETUP.md` §1 gets only a factual correction (Android driver is now registered); a full setup guide is explicit follow-up work once real-device validation is possible.
## Risks / Trade-offs
- [`dragGesture`'s `speed` (px/s) is a different shape from `Driver.swipe`'s `duration_ms`] → Mitigation: convert explicitly (`speed = distance / (duration_ms / 1000)`) with a guard for zero/near-zero distance; cover this conversion with a unit test (start==end and a normal case) rather than trusting it silently.
- [No real Android device or emulator available during this change to exercise `connect()`/`screenshot()` end-to-end] → Mitigation: mocked unit tests cover the error-handling contract; the integration test is env-var gated and simply skips until real hardware is available, exactly mirroring `WDADriver`'s current state — no regression versus today's validation depth.
- [`driver-registry` spec now carries two platform-specific example scenarios under one requirement] → Mitigation: accepted; this is the intended pattern for a third driver type in the future, not a maintenance burden.
## Migration Plan
1. Add `driver/android_driver.py` (`AndroidDriverConfig`, `AndroidDriver(Driver)`), using the confirmed UiAutomator2 mobile commands for `tap`/`swipe`/`home` (see Decisions), with a quick sanity check against whatever driver version is actually resolved in `.venv`.
2. Add `build_android_driver_factory` and register `SUPPORTED_DRIVER_TYPES["uiautomator2"]` in `driver/registry.py`.
3. Add mocked unit tests for `AndroidDriver` (connect failure, pre-connect calls, per-method exception wrapping).
4. Add `tests/test_android_integration.py`, mirroring `tests/test_wda_integration.py`'s env-var-gated structure.
5. Add the `driver-registry` spec delta scenario for `driver_type="uiautomator2"`.
6. Correct `docs/MACOS_IPHONE_SETUP.md` §1's now-outdated "Android not registered" statement.
7. Run the full non-integration test suite (`uv run --all-packages pytest -m "not integration"`) and confirm no regressions.
Rollback: everything is additive (one new driver module, one new registry entry, new test files, a one-paragraph doc correction). No data/schema migration. Rollback is deleting the new files and reverting the doc line.
## Open Questions
None outstanding. Both items originally listed here (exact UiAutomator2 mobile-command names/parameters for `tap`/`swipe`/`home`, and the system-port capability's exact key name) were resolved via research against the official `appium-uiautomator2-driver` docs — see the Decisions section.
@@ -0,0 +1,28 @@
## Why
`docs/MACOS_IPHONE_SETUP.md` 明确记录了当前的架构缺口:"当前仓库只内置了 `wda` Driver……Android 只是架构上的未来目标,当前 `driver/registry.py` 没有注册 Android Driver,因此仅安装 Android SDK/ADB 还不能让本项目控制 Android 手机。" Hexagonal + DDD 分层治理架构(`driver` 层的 Driver Registry 扩展点模式)从一开始就是为了让新增一个设备平台只需要在 `driver/` 包内添加代码,`driver/wda_driver.py` 已经把这条路径验证了一遍。现在补上 Android 驱动是把这个既定扩展点落地到第二个真实平台,不是新设计。
## What Changes
- 新增 `driver/android_driver.py``AndroidDriverConfig`(dataclass)+ `AndroidDriver(Driver)`,通过 Appium Python Client 的 UiAutomator2 driver 连接 Android 设备(真机或模拟器均可,Appium/adb 本身对两者透明),实现 `driver/base.py::Driver` 抽象基类的全部方法(connect/disconnect/screenshot/tap/swipe/input/launch/terminate/tree/home/lock/unlock)。
- `driver/registry.py`:新增 `build_android_driver_factory`,注册到 `SUPPORTED_DRIVER_TYPES["uiautomator2"]`(key 用自动化后端名而非平台名,与现有 `"wda"` 的命名惯例对称)。
- 新增 `AndroidDriver` 的 mock 单元测试(mock `appium.webdriver.Remote`),覆盖连接失败、未连接时调用、各操作异常包装为 `DriverError`/`DeviceOfflineError` 的路径——这是比 `WDADriver` 现有测试覆盖更完整的增量,`WDADriver` 目前只有一个真机门控的集成测试。
- 新增 `tests/test_android_integration.py`:结构镜像 `tests/test_wda_integration.py``@pytest.mark.integration` 门控,依赖 `APEX_ANDROID_SERVER_URL`/`APEX_ANDROID_UDID`/`APEX_ANDROID_DEVICE_NAME` 环境变量,无真机环境时 skip。
- 订正 `docs/MACOS_IPHONE_SETUP.md` 第 1 节中"Android 未注册"的过时表述,改为准确描述 Android 驱动已注册、真机安装手册留待后续变更(不在本次新增)。
- 不引入新依赖:根 `pyproject.toml` 已声明 `Appium-Python-Client>=5.1.1``.venv` 已安装 `appium.options.android.uiautomator2`
## Capabilities
### New Capabilities
(无。沿用现有惯例:单个驱动实现的具体行为不单独建 spec capability——`driver/wda_driver.py` 落地时也没有为它建一个 `wda-driver` spec,`openspec/specs/` 里只有 `driver-registry` 这一个与驱动相关的 capability,负责 registry 机制本身。为 Android 单独建一个对称的 capability 会与既有惯例不一致。)
### Modified Capabilities
- `driver-registry`:为"Building a factory for a known driver type"这条既有 Requirement 补一个 `driver_type="uiautomator2"` 的 Scenario,与既有 `driver_type="wda"` 的示例场景对称,证明这条已声明为 driver_type 无关的机制对第二个真实驱动类型同样成立。不新增强约束,只是示例覆盖的完整性。
## Impact
- **新增代码**`driver/android_driver.py``tests/test_android_integration.py`;新增覆盖 `AndroidDriver` 的 mock 单元测试文件。
- **修改代码**`driver/registry.py`(新增一个注册项,纯增量,不改动现有 `"wda"` 行为);`docs/MACOS_IPHONE_SETUP.md`(订正第 1 节一句过时表述)。
- **依赖**:无新增,复用已声明的 `Appium-Python-Client`
- **不涉及**`api/``device/``tools/``runtime/` 任一层——已用 grep 核实这些层当前不含任何 `"wda"` 字面量硬编码,`driver-registry` 既有 Requirement("Adding a driver type requires no changes outside the driver layer")保证新增驱动类型无需改动这些层。
- **Non-Goals**(本次明确不做):不新增 `docs/ANDROID_SETUP.md` 或同等深度的 Android SDK/adb 真机安装手册(用户已确认,留到驱动落地、有真机可验证后再开后续变更);不支持 Appium 的 Espresso driver(与 WDA 只做 XCUITest、不支持其他 iOS 后端对称);不做 `APEX_WDA_*``DEVICE_RUNTIME_WDA_*` 环境变量重命名(文档中记录的独立遗留事项,与本次无关)。
@@ -0,0 +1,16 @@
## MODIFIED Requirements
### Requirement: Driver type registry lives in the driver layer
The system SHALL provide a registry, owned by the `driver` package, that maps a `driver_type` string (e.g. `"wda"`, `"uiautomator2"`) to a builder function producing a `DriverFactory` for that type, so that any caller needing to construct a driver for a device does so without importing a concrete driver class directly.
#### Scenario: Building a factory for a known driver type
- **WHEN** a caller requests a driver factory for `driver_type="wda"` with connection info (e.g. `server_url`, `udid`)
- **THEN** the registry returns a `DriverFactory` that, when invoked, constructs a working `WDADriver` configured with that connection info
#### Scenario: Building a factory for the Android driver type
- **WHEN** a caller requests a driver factory for `driver_type="uiautomator2"` with connection info (e.g. `server_url`, `udid`)
- **THEN** the registry returns a `DriverFactory` that, when invoked, constructs a working `AndroidDriver` configured with that connection info
#### Scenario: Building a factory for an unknown driver type
- **WHEN** a caller requests a driver factory for a `driver_type` that is not registered
- **THEN** the registry raises a clear error naming the unsupported `driver_type`, instead of returning `None` or a factory that fails later at connect time
+39
View File
@@ -0,0 +1,39 @@
## 1. Appium UiAutomator2 command surface (confirmed via research, see design.md Decisions)
- [ ] 1.1 Sanity-check the confirmed mobile commands against whatever `appium-uiautomator2-driver` version is actually resolved in `.venv` before coding: `mobile: clickGesture` (tap), `mobile: dragGesture` (swipe/drag), `mobile: pressKey` (home), `appium:systemPort` (port isolation capability). Docs referenced (2026-07): `github.com/appium/appium-uiautomator2-driver` README and `docs/android-mobile-gestures.md`.
## 2. Driver implementation
- [ ] 2.1 Add `driver/android_driver.py` with `AndroidDriverConfig` (frozen dataclass): `server_url` (default `http://127.0.0.1:4723`), `platform_name` (default `"Android"`), `automation_name` (default `"UiAutomator2"`), `device_name`, `udid`, `system_port` (maps to the `appium:systemPort` capability), `no_reset` (default `True`), `extra_capabilities`.
- [ ] 2.2 Implement `AndroidDriver(Driver).connect()`/`disconnect()` using `appium.webdriver` + `UiAutomator2Options`, building capabilities the same way `WDADriver.connect()` does, with the same `_require_client()` guard and `DeviceOfflineError` on connect failure.
- [ ] 2.3 Implement `screenshot()`, `tree()`, `input()`, `launch()`, `terminate()`, `lock()`, `unlock()` using the same cross-platform Appium client methods `WDADriver` already uses (`get_screenshot_as_png`, `page_source`, `switch_to.active_element.send_keys`, `activate_app`, `terminate_app`, `lock`, `unlock`).
- [ ] 2.4 Implement `tap()`, `swipe()`, `home()`:
- `tap(x, y)``execute_script("mobile: clickGesture", {"x": x, "y": y})`
- `swipe(start_x, start_y, end_x, end_y, duration_ms)``execute_script("mobile: dragGesture", {"startX": start_x, "startY": start_y, "endX": end_x, "endY": end_y, "speed": speed})` where `speed = distance / (duration_ms / 1000)`, guarded against zero/near-zero distance
- `home()``execute_script("mobile: pressKey", {"keycode": 3})` (`KeyEvent.KEYCODE_HOME`)
- [ ] 2.5 Wrap every method's underlying exception into `DriverError` (`DeviceOfflineError` for connect failure and for calls made before a client exists), matching `WDADriver`'s try/except-per-method pattern exactly.
## 3. Registry wiring
- [ ] 3.1 Add `build_android_driver_factory` to `driver/registry.py`, mirroring `build_wda_driver_factory`'s logic for splitting `connection_info` into declared `AndroidDriverConfig` fields vs. `extra_capabilities`.
- [ ] 3.2 Register `SUPPORTED_DRIVER_TYPES["uiautomator2"] = build_android_driver_factory`.
## 4. Unit tests
- [ ] 4.1 Add mocked unit tests for `AndroidDriver` (mock `appium.webdriver.Remote`, no real device/emulator) covering: connect builds a client with the expected capabilities from a given `AndroidDriverConfig`; connect failure raises `DeviceOfflineError`; calling any operation before `connect()` raises `DeviceOfflineError`; each operation's underlying exception is wrapped into `DriverError`; `swipe()`'s `duration_ms``speed` conversion for both a normal case and a zero/near-zero-distance case (must not divide by zero).
- [ ] 4.2 Add a unit test for `build_android_driver_factory` covering `connection_info` field extraction and `extra_capabilities` merging (mirror `build_wda_driver_factory`'s existing test coverage if any exists; if none exists today, note that in the test file rather than silently skipping equivalent WDA coverage).
## 5. Integration test
- [ ] 5.1 Add `tests/test_android_integration.py` mirroring `tests/test_wda_integration.py`'s structure: `@pytest.mark.integration`, `pytest.skip` when `APEX_ANDROID_SERVER_URL` is unset, optional `APEX_ANDROID_UDID`/`APEX_ANDROID_DEVICE_NAME`, connects and asserts a non-empty `screenshot()` before disconnecting.
## 6. Spec and docs
- [ ] 6.1 Confirm `openspec/changes/android-driver/specs/driver-registry/spec.md`'s `driver_type="uiautomator2"` scenario still matches the shipped registry key and behavior exactly; update the delta if anything changed during implementation (e.g. the system-port capability name).
- [ ] 6.2 Correct `docs/MACOS_IPHONE_SETUP.md` §1: replace "Android 只是架构上的未来目标,当前 driver/registry.py 没有注册 Android Driver" with an accurate statement that the Android driver is registered, while a full real-device setup guide remains separate follow-up work.
## 7. Verification
- [ ] 7.1 Run `uv run --all-packages pytest -m "not integration"` and confirm no regressions.
- [ ] 7.2 Run the project's lint/format checks against the new files and fix any violations.
- [ ] 7.3 Run `openspec validate android-driver --strict` and confirm it passes.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-13
+57
View File
@@ -0,0 +1,57 @@
## Context
`apps/cloud-api/cloud_api/app.py` composes exactly two routers today: `create_cloud_router` (the versioned `/v1` platform SDK, `packages/cloud-platform/cloud/sdk/api.py`) and `create_internal_router` (Host Agent-only heartbeat/claim/renew/result). Neither mounts any static assets or HTML — confirmed by reading `app.py` in full, it has no `StaticFiles`/template mount. There is also **no CORS middleware configured** on the Cloud API app, unlike the local Runtime's `api/rest.py`, which enables permissive CORS specifically so the existing `console/` SPA can call it cross-origin during `npm run dev`.
Auth on `/v1/...` is `ConfiguredBearerAuthProvider` (`cloud/auth.py`): a flat list of pre-shared opaque tokens, each mapped to a `Principal(id, scopes, host_id)`. There is no login flow, no session, no user directory — every integrator (Host Agent, `CloudClient`, and now this console) authenticates the same way, by presenting `Authorization: Bearer <token>` on each request.
The repository (`cloud/repository.py` Protocol, implemented in `sql_repository.py` for both SQLite and PostgreSQL via the same SQLAlchemy code path) exposes `list_queued_tasks()` (queued-only, used internally by `TaskScheduler.assign()`) and `get_task(task_id)` (single lookup, backs `GET /v1/tasks/{id}`), but nothing that lists tasks across all statuses, and nothing paginated. `list_task_attempts(task_id)` is fully implemented (`sql_repository.py:531`) but has no route anywhere — it exists only for internal/audit use today.
## Goals / Non-Goals
**Goals:**
- Give an operator read visibility into tasks (all statuses, including history and per-attempt outcomes), the device pool, the host registry, and the plugin registry, without scripting REST calls.
- Let an operator perform the two write actions that are already safe, scope-gated, and exposed today — submit an ad-hoc task and register a plugin manifest — from the same UI, instead of adding new write capabilities.
- Reuse the Cloud Control Plane's existing bearer-token auth model exactly as `CloudClient` does — zero new auth surface.
**Non-Goals:**
- Task cancellation or forced retry — no scheduler/repository operation for this exists (`assign`/`claim`/`renew`/`record_result` only); adding one is a scheduling-capability change out of this proposal's scope.
- Host Agent remote start/stop — the Host Agent only initiates outbound calls (heartbeat/claim/renew/result); the control plane has no channel to push commands to it.
- Plugin de-registration — `CloudRepository` has no delete/deregister method for plugins.
- A new login/session/RBAC/user-management system — the console is just another bearer-token holder, provisioned the same way as any `CLOUD_PUBLIC_CREDENTIALS_JSON` entry.
- Real-time push (WebSocket/SSE) — polling only.
- A shared component library or JS monorepo tooling spanning `console/` and the new `cloud-console/` — they stay two fully independent SPA projects, matching `console/README.md`'s own "Independent Vue 3 + Vite SPA" precedent.
- Multi-cluster/multi-control-plane aggregation — `docs/CLOUD_DEPLOYMENT.md` already limits deployment to one scheduler-enabled Cloud API process; the console targets one Cloud API base URL at a time.
## Decisions
- **Extend `platform-sdk` rather than invent a parallel console-only API.** Add `GET /v1/tasks` and `GET /v1/tasks/{task_id}/attempts` to the existing `cloud/sdk/api.py` router, under the same `/v1` prefix, same `tasks:read` scope, same `AuthProvider` hook. Rejected alternative: a separate `console-api` router mirroring the local Runtime's `console-status-api`/`console-config-api` split — rejected because the Cloud Control Plane's `/v1` surface is already the one stable, versioned, scope-gated contract every integrator uses; a second parallel surface would duplicate auth wiring for no benefit and would fork `platform-sdk`'s existing "client mirrors REST API" requirement into two inconsistent contracts.
- **New bounded, filterable task listing at the repository layer.** Add `list_tasks(*, status: ScheduledTaskStatus | None, limit: int, offset: int) -> list[ScheduledTask]` and `count_tasks(status: ScheduledTaskStatus | None) -> int` to the `CloudRepository` Protocol, implemented via the same SQLAlchemy query builder `sql_repository.py` already uses for both SQLite and PostgreSQL (no dialect-specific SQL branch). `GET /v1/tasks` caps `limit` server-side (`Field(..., le=100)`, default 50), ordered most-recent-first, mirroring the bounded-`Field` pattern `internal_api/models.py` already uses for `ClaimRequest.timeout_seconds`. Rejected alternative: reusing/widening `list_queued_tasks()` — rejected because it is scheduler-internal (assign-loop candidate selection), has no status filter or pagination, and callers outside the scheduler have no business depending on its exact contract.
- **`GET /v1/tasks/{task_id}/attempts` is a thin pass-through** to the existing `list_task_attempts()`, no new repository work — it just needed a route and a response model.
- **Extend `CloudClient` in the same change.** `platform-sdk`'s existing requirement states the Python client mirrors every `/v1/...` route; adding routes without adding client methods would silently break that invariant. Add `list_tasks(...)` and `get_task_attempts(...)` to `cloud/sdk/client.py` with matching tests.
- **Bearer token pasted by the operator, held in `sessionStorage`, never in `localStorage`.** On load, the console shows a token-entry screen if no token is present; every subsequent request attaches `Authorization: Bearer <token>`, exactly like `CloudClient`. Rejected alternative: building any login/username-password flow — there is no user directory to authenticate against; the Cloud Control Plane's entire identity model is pre-issued scoped tokens, and inventing a session layer on top would be new auth infrastructure this proposal has no reason to add. Operators are expected to hold a token scoped at least to `tasks:read`+`pool:read`+`plugins:read` (add `tasks:submit`/`plugins:admin` only if the console's write actions are needed), provisioned the same way as any other `CLOUD_PUBLIC_CREDENTIALS_JSON` entry.
- **Add configurable, closed-by-default CORS to the Cloud API.** `apps/cloud-api/cloud_api/app.py` currently has zero CORS middleware. Add an env-driven allow-list (e.g. `CLOUD_CONSOLE_CORS_ORIGINS`, comma-separated, empty by default) wired through `CloudControlConfig`/`load_control_config()`, applied via FastAPI's `CORSMiddleware` only when non-empty. Rejected alternative: permissive CORS like the local Runtime's dev-only setup — rejected because the Cloud API, unlike the single-developer local Runtime, is meant for real deployment (PostgreSQL, production `CLOUD_ENVIRONMENT`) where the entire auth model is a bearer token; blanket `allow_origins=["*"]` would let any origin that tricks an operator's browser into sending that token succeed. Default-empty keeps every existing deployment's behavior unchanged until an operator opts in with their console's actual origin.
- **New top-level `cloud-console/` project, sibling to `console/`, not nested under `packages/cloud-platform` or `apps/cloud-api`.** Mirrors the existing `console/` precedent (independent Node/Vue toolchain kept out of the Python `uv` workspace) and keeps the two SPAs — and the two backends and auth models they talk to — visibly separate rather than implying a shared deployment unit.
## Risks / Trade-offs
- [No CORS today on the Cloud API] → Mitigation: ship the allow-list closed by default; existing deployments see no behavior change until they configure their console's origin.
- [Bearer token lives in browser storage] → Mitigation: `sessionStorage` only (cleared on tab close), never logged, sent only to the configured Cloud API base URL; document least-privilege scoping and the existing token-rotation guidance from `docs/CLOUD_DEPLOYMENT.md`.
- [Task history is unbounded over time, unlike the depth-capped queue] → Mitigation: server-side hard cap on page size (`le=100`), default ordering most-recent-first; client paginates rather than fetching everything.
- [New repository method must behave identically on SQLite and PostgreSQL] → Mitigation: implement through the same SQLAlchemy query builder every other `sql_repository.py` method already uses; cover both backends in the existing dual-backend repository test suite.
- [Two independent frontends (`console/`, `cloud-console/`) can drift in look-and-feel] → Mitigation: accepted for this change's scope, matching `console/`'s own "independent" precedent; revisit a shared design system only if a third console appears.
## Migration Plan
1. Add `list_tasks`/`count_tasks` to the `CloudRepository` Protocol and `sql_repository.py`, with unit/integration tests against both SQLite and PostgreSQL.
2. Add `GET /v1/tasks` and `GET /v1/tasks/{task_id}/attempts` to `cloud/sdk/api.py`, with response models in `cloud/sdk/models.py`.
3. Add matching `CloudClient` methods and tests in `cloud/sdk/client.py`.
4. Add the closed-by-default CORS allow-list to `apps/cloud-api/cloud_api/app.py` / `cloud/control_config.py`.
5. Scaffold `cloud-console/` (Vue 3 + Vite): token-entry screen, an API client wrapper that attaches the bearer token, and routing shell.
6. Build the dashboard views: task list/detail/attempts, device pool, host registry, plugin registry + registration form.
7. Document console usage and operator token provisioning in `docs/CLOUD_DEPLOYMENT.md`.
8. Rollback: everything is additive — two new GET routes, two new repository methods, opt-in CORS middleware, a new frontend project. No schema migration and no changes to existing task/attempt table columns, so rollback is simply removing the new routes/middleware and not deploying the frontend.
## Open Questions
- Should live task status use SSE/WebSocket push instead of polling? Deferred — polling matches this change's scale; revisit only if operators report polling latency is actually a problem.
- Should credential provisioning offer a bundled "console" scope alias instead of operators requesting `tasks:read`+`pool:read`+`plugins:read` separately? Deferred — documenting the combination in `docs/CLOUD_DEPLOYMENT.md` is enough for now; scope aliasing is an auth-model change beyond this proposal's impact.
@@ -0,0 +1,29 @@
## Why
The Cloud Control Plane (`apps/cloud-api` + `packages/cloud-platform/cloud`) is reachable only through its versioned REST/SDK surface — there is no Web UI. An operator who wants to see queue backlog, device/host health, or plugin registrations today has to script calls against `/v1/*` or query the database directly. Now that `cloud-control-plane-integration` has landed and archived the remote scheduling/lease/Host Agent loop, the missing operational visibility layer is the clearest gap left before this control plane is easy to run day-to-day. The existing `console/` SPA does not cover this: it talks only to the local single-device Runtime API (port 8000), not the Cloud Control Plane (port 8001, scoped bearer auth).
## What Changes
- Add a **task listing endpoint** (`GET /v1/tasks`, `tasks:read` scope) returning tasks across all statuses with status filtering and bounded pagination — today only `POST /v1/tasks` (submit) and `GET /v1/tasks/{id}` (single lookup) exist; the repository itself only exposes `list_queued_tasks()` (queued-only, used internally by the scheduler).
- Add a **task attempt-history endpoint** (`GET /v1/tasks/{task_id}/attempts`, `tasks:read` scope) exposing the already-implemented `CloudRepository.list_task_attempts()`, which today has no route at all.
- Extend the Python `CloudClient` with corresponding methods for both new endpoints, preserving `platform-sdk`'s existing requirement that the client mirrors every `/v1/...` route.
- Add an **independent Web console frontend** (new `cloud-console/` Vue 3 + Vite SPA at the repo root, sibling to the existing `console/`) that authenticates with an operator-supplied bearer token (pasted in, held client-side) and renders:
- a task queue/history view (list + filter by status, detail with attempt history),
- a device pool and host registry view (including staleness/unreachable status),
- a plugin registry view with a form to submit a new plugin manifest (`POST /v1/plugins`).
- Explicitly out of scope: task cancellation or retry-from-UI (no such scheduler operation exists), Host Agent remote start/stop (Host Agent only makes outbound calls; the control plane cannot push commands to it), plugin de-registration (no repository method exists), and any login/session/RBAC system beyond pasting a pre-issued scoped bearer token (mirrors how `CloudClient` already authenticates — no new auth mechanism).
## Capabilities
### New Capabilities
- `cloud-console-ui`: Independent Vue 3 + Vite SPA for the Cloud Control Plane, consuming the platform SDK's `/v1` REST surface (existing endpoints plus the two added by this change) to provide read dashboards for tasks/devices/hosts/plugins and a plugin-registration action, authenticated via an operator-supplied bearer token.
### Modified Capabilities
- `platform-sdk`: add task listing (`GET /v1/tasks`, filterable/paginated) and task attempt-history (`GET /v1/tasks/{task_id}/attempts`) requirements to the existing versioned `/v1` surface, both scope-gated by `tasks:read`; extend the `CloudClient` requirement so it continues to mirror every route.
## Impact
- **New code**: routes and response models in `packages/cloud-platform/cloud/sdk/api.py` / `cloud/sdk/models.py`; a new bounded/filtered task-listing method on `CloudRepository` (`repository.py` Protocol) implemented in `sql_repository.py`; new `CloudClient` methods in `cloud/sdk/client.py`; a new top-level `cloud-console/` Vue 3 + Vite SPA project (own `package.json`/build, no Python dependency).
- **Modified code**: `cloud/sdk/api.py` router gains two GET routes; no change to existing submission, assignment, claim, renewal, or result-recording behavior.
- **Dependencies**: no new backend dependency (reuses FastAPI/Pydantic already in `packages/cloud-platform`); the new frontend brings its own Node/Vue 3/Vite toolchain, matching the existing `console/` project's pattern.
- **Docs**: `docs/CLOUD_DEPLOYMENT.md` gains a section on running the console and obtaining/rotating an operator bearer token for it.
@@ -0,0 +1,53 @@
## ADDED Requirements
### Requirement: Operator authenticates with a bearer token
The console SHALL require an operator-supplied bearer token before calling any Cloud Control Plane endpoint, SHALL hold that token only in browser session storage, and SHALL attach it as an `Authorization: Bearer` header on every request.
#### Scenario: No token present
- **WHEN** an operator opens the console without a previously entered token
- **THEN** the console shows a token-entry screen instead of any dashboard view
#### Scenario: Token rejected by the Cloud API
- **WHEN** the Cloud Control Plane responds `401` or `403` to a request carrying the stored token
- **THEN** the console clears the stored token and returns to the token-entry screen with a clear message
#### Scenario: Tab closed
- **WHEN** an operator closes the browser tab running the console
- **THEN** the stored bearer token is discarded and is not available on the next visit
### Requirement: Task dashboard
The console SHALL render a task view listing tasks by status with pagination, and SHALL show a task's detail including its attempt history, using the platform SDK's task-listing and attempt-history endpoints.
#### Scenario: Browse the task queue
- **WHEN** an operator with a `tasks:read`-scoped token opens the task view
- **THEN** the console displays tasks with their status, goal or workflow reference, and assigned device/host, and lets the operator filter by status
#### Scenario: Inspect a task's attempt history
- **WHEN** an operator selects a task from the list
- **THEN** the console displays that task's recorded attempts in order, including each attempt's outcome
### Requirement: Device pool and host registry views
The console SHALL render the current device pool and host registry, including stale/unreachable device status, using the platform SDK's device- and host-listing endpoints.
#### Scenario: View devices across hosts
- **WHEN** an operator with a `pool:read`-scoped token opens the device view
- **THEN** the console displays every pooled device with its owning host, driver type, and current status, including `unreachable` for devices owned by a stale host
#### Scenario: View registered hosts
- **WHEN** an operator opens the host view
- **THEN** the console displays every registered host with its last-seen timestamp
### Requirement: Plugin registry view with registration
The console SHALL render the registered plugin list and SHALL let an operator submit a new plugin manifest for registration, using the platform SDK's plugin-listing and plugin-registration endpoints.
#### Scenario: View registered plugins
- **WHEN** an operator with a `plugins:read`-scoped token opens the plugin view
- **THEN** the console displays every registered plugin with its `entry_point_kind` and whether it is wired to an execution path
#### Scenario: Register a plugin from the console
- **WHEN** an operator with a `plugins:admin`-scoped token submits a valid plugin manifest through the console's registration form
- **THEN** the console calls the plugin-registration endpoint and displays the newly registered plugin on success, or the API's validation/conflict error on failure
#### Scenario: Registration attempted without admin scope
- **WHEN** an operator whose token lacks `plugins:admin` submits the registration form
- **THEN** the console surfaces the API's authorization error without retrying or silently discarding the submission
@@ -0,0 +1,40 @@
## ADDED Requirements
### Requirement: Task listing via the SDK
The system SHALL allow an external integrator to list tasks known to the `task-scheduler` capability across all statuses, through the platform SDK's API, with optional status filtering and bounded pagination.
#### Scenario: List tasks without a filter
- **WHEN** an integrator with `tasks:read` calls the task-listing endpoint with no status filter
- **THEN** the API returns tasks across all statuses, most recently created first, bounded to the requested (or default) page size
#### Scenario: List tasks filtered by status
- **WHEN** an integrator calls the task-listing endpoint with a status filter (e.g. `failed`)
- **THEN** the API returns only tasks currently in that status
#### Scenario: Page size exceeds the maximum
- **WHEN** an integrator requests a page size above the API's configured maximum
- **THEN** the API rejects the request rather than returning an unbounded result set
### Requirement: Task attempt history via the SDK
The system SHALL allow an external integrator to retrieve the attempt history of a known task — each attempt's assignment, lease, and terminal outcome — through the platform SDK's API, backed by the `task-scheduler` capability's attempt records.
#### Scenario: Retrieve attempt history for a known task
- **WHEN** an integrator with `tasks:read` requests the attempt history for a task id that exists
- **THEN** the API returns every recorded attempt for that task in chronological order, including its outcome
#### Scenario: Retrieve attempt history for an unknown task
- **WHEN** an integrator requests attempt history for a task id that does not exist
- **THEN** the API returns a not-found response rather than an unhandled server error
## MODIFIED Requirements
### Requirement: Python SDK client mirrors the REST API
The system SHALL provide a Python client (`CloudClient`) exposing methods corresponding to each `/v1/...` route (submit task, get task status, list tasks, get task attempt history, list devices, list hosts, list plugins, register plugin), so integrators do not need to hand-construct HTTP requests.
#### Scenario: Client submits a task and retrieves status
- **WHEN** a caller uses `CloudClient` to submit a task and then fetch its status by the returned id
- **THEN** the client's methods produce the same result as calling the corresponding `/v1/...` endpoints directly over HTTP
#### Scenario: Client lists tasks and retrieves attempt history
- **WHEN** a caller uses `CloudClient` to list tasks with a status filter and then fetch attempt history for one returned task id
- **THEN** the client's methods produce the same result as calling the corresponding `/v1/...` endpoints directly over HTTP
+42
View File
@@ -0,0 +1,42 @@
## 1. Repository: bounded task listing
- [x] 1.1 Add `list_tasks(*, status, limit, offset)` and `count_tasks(status)` to the `CloudRepository` Protocol in `repository.py`
- [x] 1.2 Implement both methods in `sql_repository.py` using the existing SQLAlchemy query builder (no dialect-specific SQL), ordered most-recent-first
- [x] 1.3 Add unit/integration tests covering status filtering, pagination bounds, and empty results against both SQLite and PostgreSQL
## 2. Platform SDK API: task listing & attempt history
- [x] 2.1 Add response models (task summary list item, task attempt) to `cloud/sdk/models.py`
- [x] 2.2 Implement `GET /v1/tasks` in `cloud/sdk/api.py`: `tasks:read` scope, optional `status` query param, `limit` (default 50, max 100) / `offset` query params, calling the new repository methods
- [x] 2.3 Implement `GET /v1/tasks/{task_id}/attempts` in `cloud/sdk/api.py`: `tasks:read` scope, 404 on unknown task id, calling `list_task_attempts`
- [x] 2.4 Add tests for both endpoints: filtered/unfiltered listing, page-size-exceeds-max rejection, attempts for known/unknown task id, and scope enforcement (401/403)
## 3. Python SDK client parity
- [x] 3.1 Add `list_tasks(...)` and `get_task_attempts(task_id)` methods to `CloudClient` in `cloud/sdk/client.py`
- [x] 3.2 Add client tests asserting parity with direct HTTP calls to the two new endpoints
## 4. Cloud API CORS configuration
- [x] 4.1 Add a `cors_allowed_origins` field (env `CLOUD_CONSOLE_CORS_ORIGINS`, comma-separated, default empty) to `CloudControlConfig`/`load_control_config()`
- [x] 4.2 Wire `CORSMiddleware` into `apps/cloud-api/cloud_api/app.py`'s `create_app()`, added only when the allow-list is non-empty
- [x] 4.3 Add a config/app test confirming CORS headers are absent by default and present only for a configured origin
## 5. Cloud console frontend (independent SPA)
- [x] 5.1 Scaffold an independent Vue 3 + Vite SPA project at `cloud-console/` (own `package.json`/build tooling, sibling to `console/`)
- [x] 5.2 Implement the token-entry screen and an API client wrapper that stores the bearer token in `sessionStorage` and attaches it to every request, clearing it and returning to the entry screen on `401`/`403`
- [x] 5.3 Implement the task view: filterable/paginated list against `GET /v1/tasks`, and a detail view with attempt history against `GET /v1/tasks/{id}/attempts`
- [x] 5.4 Implement the device pool and host registry views against `GET /v1/devices` and `GET /v1/hosts`
- [x] 5.5 Implement the plugin registry view (list) and registration form against `GET /v1/plugins` and `POST /v1/plugins`, surfacing validation/conflict/authorization errors from the API
- [x] 5.6 Document how to run the frontend dev server against a Cloud API base URL (env config) and the CORS origin it needs configured
## 6. Documentation
- [x] 6.1 Add a section to `docs/CLOUD_DEPLOYMENT.md` covering: running the console, provisioning an operator bearer token (least-privilege scopes), and configuring `CLOUD_CONSOLE_CORS_ORIGINS`
## 7. Verification
- [x] 7.1 Run the full backend test suite (`uv run --all-packages pytest -m "not integration"`) and confirm no regressions
- [ ] 7.2 Run the PostgreSQL-backed repository/integration tests for the new listing methods
- [ ] 7.3 Manually verify end-to-end: submit a task via the existing SDK, confirm it appears in the console's task list, transitions status, and its attempt history renders; confirm device/host/plugin views render against a running Host Agent
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-13
@@ -0,0 +1,111 @@
## Context
The current Cloud Control Plane authenticates Host Agents from a static environment-provided credential list. A Host Agent must start with a pre-agreed `host_id`, bearer token, and locally chosen device IDs; its first heartbeat implicitly creates the host and pooled-device rows. This works for controlled development but creates manual coordination, identity collisions, and unsafe retry behavior for repeatable edge deployment.
The change crosses Cloud API authentication, durable repository state, migrations, internal protocol models, Host Agent startup, and local device configuration. It must preserve the established outbound-only Host Agent protocol, PostgreSQL/SQLite parity, the existing static-credential deployment path, and the Runtime dependency direction: enrollment remains an outer cloud/application concern and does not enter `core`, `driver`, `device`, or `tools`.
## Goals / Non-Goals
**Goals:**
- Allow a new edge installation to start with a control-plane URL and one-time enrollment token instead of pre-coordinated Host and device IDs.
- Make the Cloud Control Plane authoritative for generated `host_id` and `device_id` values.
- Keep enrollment retries idempotent across response loss and process restart without persisting plaintext long-lived Host secrets in cloud storage.
- Persist Host credentials and device enrollment mappings through the existing repository abstraction for both PostgreSQL and SQLite.
- Let dynamically enrolled Host Agents authenticate normal heartbeat, claim, renew, and result requests through the existing Host-bound authorization contract.
- Preserve explicit `HOST_AGENT_HOST_ID` and `HOST_AGENT_TOKEN` configuration as a compatible legacy mode.
- Protect existing local Runtime device IDs by storing the cloud mapping separately and translating managed assignments at Host Agent composition time.
**Non-Goals:**
- Automatic discovery of iPhones from USB/Appium; operators still create local device configuration records.
- A public enrollment-management UI, tenant model, certificate authority, mTLS, or remote Host installation service.
- Silent transfer of an enrolled device between Hosts. Moving a phone creates a new Host-scoped device enrollment unless a future explicit transfer capability is added.
- Distribution of Appium/WDA signing configuration, LLM credentials, or workflow definitions from the cloud.
- Removal of static Host credentials in this change.
## Decisions
### D1. Separate bootstrap enrollment from the operational Host protocol
Add `POST /internal/v1/enrollments` authenticated by a configured enrollment token. The endpoint is the only internal route that does not require an existing Host-bound principal. Heartbeat, claim, renewal, result reporting, and device enrollment continue to require a Host-bound bearer credential.
Alternative considered: allow an unknown Host to create itself through heartbeat. Rejected because heartbeat would mix bootstrap authentication, identity creation, and state replacement, and an interrupted first heartbeat would be difficult to distinguish from credential misuse.
### D2. The edge generates the long-lived Host secret; the cloud generates the Host ID
Before its first enrollment request, the Host Agent generates and durably stores an `agent_instance_id` and a high-entropy bearer token. The enrollment request sends the candidate Host token over TLS while authenticating with the one-time enrollment token. The cloud generates `host_id`, stores only the Host token digest, and binds it to the instance.
This makes response-loss retries safe: the same instance can resend the same candidate secret, and the server can verify its digest and return the same `host_id`. The cloud never needs to retain or re-return plaintext Host credentials.
Alternative considered: cloud-generated Host secret returned once. Rejected because a lost response would require either unrecoverable enrollment or plaintext/encrypted secret recovery state on the server.
### D3. Enrollment tokens are configured but consumption is durable
`CLOUD_ENROLLMENT_TOKENS_JSON` supplies high-entropy bootstrap tokens to the Cloud API. Authentication compares token digests without logging token values. The Host enrollment transaction records the enrollment-token digest used by an instance, with a uniqueness constraint so a token cannot enroll a second instance after restart.
An identical retry for the same `agent_instance_id` and Host credential digest returns the existing Host identity. Reuse for another instance, or retry with a different candidate Host token, returns a conflict.
### D4. Dynamic Host credentials compose with existing configured credentials
Add a repository-backed Host `AuthProvider` that hashes the presented bearer token and resolves a non-revoked enrolled Host. Compose it after the existing configured bearer provider. Public SDK scopes remain configuration-driven; dynamically enrolled credentials receive only a Host-bound principal and therefore cannot call public operator APIs.
Static Host credentials retain current behavior and local device IDs. This limits migration risk and permits staged deployment.
### D5. Durable device enrollment is separate from transient pool state
Add a `device_enrollments` table with cloud-generated `device_id`, owning `host_id`, opaque `local_device_id`, driver metadata, enrollment timestamps, and revocation state. Enforce uniqueness for both `device_id` and `(host_id, local_device_id)`.
`POST /internal/v1/hosts/{host_id}/devices/enroll` is Host-authenticated. Repeating the same Host/local-device pair returns the same cloud ID and may refresh non-identity metadata. A different Host receives a different ID even if it reports the same physical phone.
Pooled-device rows remain replaceable heartbeat projections. For enrollment-managed Hosts, a heartbeat may report only non-revoked device IDs enrolled to that Host, with matching driver type. Legacy statically authenticated Hosts retain the existing snapshot behavior.
### D6. Local Runtime identity and cloud identity remain distinct
Extend `DeviceConfigStore` records with nullable `cloud_device_id`. Existing `device_id` remains the local Runtime/configuration key and is used as the opaque enrollment reference; no raw iPhone UDID must be sent solely for enrollment.
In managed mode the Host Agent enrolls every configured device before constructing its `DeviceManager`, persists returned mappings, and registers drivers under the cloud `device_id`. Assignment execution therefore continues to use the existing `DeviceManager` and tool contracts without adding translation logic to Runtime layers. Legacy mode registers the existing local IDs unchanged.
### D7. Host identity state is written before and after network enrollment
Use an edge-local identity file under the mounted tasks/state path. Before the first request, atomically persist the generated instance ID and Host token; after a successful response, atomically add the assigned Host ID. Restrict file permissions to the current user where the operating system supports it. Environment-provided explicit Host credentials take precedence and do not overwrite managed identity state.
This state is a secret and must be backed up or deliberately revoked before replacement. Losing it causes a new enrollment rather than unsafe guessing of a prior identity.
### D8. Schema revision 0002 carries enrollment state
Add a forward/downgrade Alembic revision after `0001_cloud_repository`. Extend `host_registrations` with nullable instance, credential, enrollment-token, display-name, enrolled-at, and revoked-at fields, plus required uniqueness/indexes. Add `device_enrollments`. Fresh local/test databases continue to use SQLAlchemy metadata creation; production readiness requires revision 0002.
The repository owns atomic Host enrollment, credential lookup/revocation, device enrollment/lookup, and managed-snapshot validation queries. HTTP handlers do not assemble multi-step uniqueness checks outside the transaction.
## Risks / Trade-offs
- [Identity file theft permits Host impersonation] -> Store only on the edge host, set restrictive permissions, keep it outside images/source control, use HTTPS, and support repository-level revocation.
- [Configured enrollment tokens remain present after consumption] -> Persist token-digest consumption with a unique constraint so application restart or unchanged environment configuration cannot reuse them.
- [Static and managed modes increase transitional complexity] -> Make the mode explicit in resolved Host Agent configuration and cover both paths with contract tests; do not silently convert a static deployment.
- [Device mapping is lost locally] -> Re-enrollment is idempotent by `(host_id, local_device_id)` and reconstructs the same cloud ID when Host identity state remains available.
- [Phone movement creates multiple historical device IDs] -> Treat enrollment as a Host attachment for this release; require future explicit transfer semantics before preserving identity across Hosts.
- [Database-backed auth adds a query to Host requests] -> Query by an indexed SHA-256 digest. Optimize with bounded caching only after measurement; revocation correctness takes priority.
- [Rollback cannot authenticate newly enrolled Hosts] -> Keep static credential support and require operators to provision temporary static credentials before rolling back application/schema.
## Migration Plan
1. Deploy the schema migration while existing Cloud API and Host Agent versions are stopped or compatible with the additive schema.
2. Deploy the Cloud API with `CLOUD_ENROLLMENT_TOKENS_JSON`; retain existing `CLOUD_HOST_CREDENTIALS_JSON` during migration.
3. Verify readiness and enrollment API tests, then deploy new Host Agents.
4. Existing explicitly configured Host Agents continue in legacy mode. New edge installations use enrollment mode and persist identity/device mappings under their tasks/state volume.
5. After all managed Hosts are verified, rotate or remove no-longer-required static Host credentials independently; public SDK credentials remain configured.
Rollback requires stopping managed Host Agents, provisioning static Host credentials and IDs for any Host that must continue operating on the previous release, then downgrading the schema to revision 0001. The downgrade removes dynamic credentials and durable device enrollments but leaves legacy hosts, pooled devices, tasks, attempts, and plugins intact.
## Open Questions
No blocking questions remain for the first implementation. Certificate-based Host identity, enrollment-token administration APIs, and cross-Host device transfer are intentionally deferred.
## Verification Notes
- Repository, migration, authentication, Cloud API, Host Agent, deployment-contract, and existing end-to-end tests pass in the local SQLite/non-integration environment.
- PostgreSQL behavior is covered by the shared parameterized repository contract but was not executed without `TEST_POSTGRES_URL`.
- No real macOS/iPhone/Appium environment was available to validate first enrollment against physical hardware.
- Host revocation is implemented at the repository/operations layer; a public administrative revocation API and UI remain out of scope.
- Device discovery remains explicit local configuration, and device identity transfer between Hosts remains intentionally unsupported.
@@ -0,0 +1,31 @@
## Why
Deploying a Device Host Agent currently requires an operator to pre-coordinate both `host_id` and every `device_id` with the Cloud Control Plane. This makes edge installation brittle and prevents a cloud-authoritative onboarding flow where a trusted control plane assigns stable identities after a host proves possession of a bootstrap credential.
## What Changes
- Add an authenticated Host enrollment operation that consumes a configured one-time enrollment token and returns a cloud-generated `host_id`.
- Let the enrolling Host Agent generate its long-lived bearer secret locally, so retries remain idempotent without the control plane storing or returning plaintext credentials.
- Persist dynamically enrolled host credential digests in the cloud repository while retaining existing statically configured Host credentials for compatibility.
- Add an authenticated device enrollment operation that maps a host-scoped opaque local device reference to a cloud-generated `device_id`.
- Persist the cloud device mapping separately from transient heartbeat state and require heartbeat snapshots to use device IDs assigned to the authenticated host.
- Add Host Agent bootstrap and identity persistence so an edge installation can start with only the control-plane URL and an enrollment token, recover safely after response loss, and reuse assigned IDs after restart.
- Extend local device configuration with an optional cloud device mapping while keeping existing local Runtime device identifiers compatible.
- Update deployment configuration and operator documentation for enrollment-token provisioning, identity-state protection, migration, revocation, and static-credential fallback.
## Capabilities
### New Capabilities
- `edge-host-enrollment`: Secure, idempotent Host and device onboarding with cloud-assigned identities and edge-persisted credential/mapping state.
### Modified Capabilities
- `host-agent-protocol`: Permit bootstrap enrollment before the normal authenticated heartbeat/claim protocol and require enrolled cloud device IDs in later Host Agent traffic.
- `device-pool`: Separate durable device enrollment identity from transient heartbeat state and accept only device identities assigned to the reporting host.
- `cloud-control-plane`: Persist dynamic Host credentials and enrollment records through the shared PostgreSQL/SQLite repository and schema migration contract.
## Impact
- Cloud authentication, internal Host Agent API models/routes, repository protocol, SQLAlchemy models, migrations, and application composition.
- Device Host Agent configuration, startup/bootstrap client, local identity persistence, device configuration mapping, heartbeat construction, and assignment execution lookup.
- Environment variables, Compose wiring, deployment documentation, repository/HTTP/Host Agent tests, migration tests, and OpenSpec main capability contracts after archive.
- Existing deployments using `CLOUD_HOST_CREDENTIALS_JSON` with explicit `HOST_AGENT_HOST_ID` and `HOST_AGENT_TOKEN` remain supported during migration.
@@ -0,0 +1,44 @@
## MODIFIED Requirements
### Requirement: Deployment and local persistence modes share one contract
The cloud repository SHALL support PostgreSQL for deployed operation and SQLite for local development and tests through the same behavioral contract, including hosts, dynamic Host credential bindings, device enrollments, pooled devices, tasks, leases, attempts, and plugins.
#### Scenario: Start with PostgreSQL
- **WHEN** the configured database URL selects PostgreSQL and the schema is current
- **THEN** the control plane uses PostgreSQL for cloud state, enrollment idempotency, authentication lookup, and transactional assignment operations
#### Scenario: Start in local SQLite mode
- **WHEN** the configured database URL selects SQLite in a local or test environment
- **THEN** the same repository contract, including Host and device enrollment, is available with the documented single-control-plane concurrency limitation
### Requirement: Cloud schema is versioned with migrations
The system SHALL provide versioned forward and downgrade database migrations and SHALL refuse readiness when the database schema is incompatible with the running application.
#### Scenario: Upgrade an existing cloud database
- **WHEN** an operator applies the enrollment release migration to a database at revision 0001
- **THEN** existing hosts, pooled devices, tasks, attempts, and plugins are retained while nullable Host enrollment fields and durable device enrollment storage are added
#### Scenario: Downgrade the enrollment schema
- **WHEN** an operator downgrades revision 0002 while no enrollment-capable application process is connected
- **THEN** dynamic Host credential and device enrollment storage is removed while legacy cloud state from revision 0001 remains available
#### Scenario: Schema is behind at startup
- **WHEN** the application connects to a database whose schema version is not accepted by the running release
- **THEN** readiness fails with a diagnostic that does not expose credentials
## ADDED Requirements
### Requirement: Dynamic Host authentication uses durable credential digests
The Cloud Control Plane SHALL authenticate dynamically enrolled Host bearer credentials through indexed repository lookup of a cryptographic token digest and SHALL compose that lookup with existing configured credentials.
#### Scenario: Enrolled Host authenticates after Cloud API restart
- **WHEN** a non-revoked enrolled Host presents its bearer credential after the Cloud API restarts
- **THEN** the repository-backed authentication provider resolves the stored Host binding and authorizes only Host-scoped internal operations
#### Scenario: Dynamic Host credential calls a public route
- **WHEN** a dynamically enrolled Host credential is presented to a public SDK operation requiring a scope
- **THEN** the request is rejected for missing scope rather than inheriting public operator privileges
#### Scenario: Static credential deployment remains active
- **WHEN** an operator continues to configure a Host-bound credential through the existing environment configuration
- **THEN** that Host can use the existing operational protocol without performing bootstrap enrollment
@@ -0,0 +1,40 @@
## MODIFIED Requirements
### Requirement: Authenticated network synchronization feeds the device pool
The system SHALL expose an authenticated Host Agent operation that validates a host device snapshot against its authentication mode and durable device enrollments before delegating it to the existing device-pool synchronization behavior.
#### Scenario: Valid managed remote snapshot
- **WHEN** an authenticated enrollment-managed Host submits a complete snapshot containing only non-revoked device IDs enrolled to that Host with matching driver types
- **THEN** the device pool refreshes that Host and its devices with the same replacement and staleness semantics as an in-process synchronization call
#### Scenario: Valid legacy remote snapshot
- **WHEN** an authenticated statically configured Host submits a valid complete snapshot
- **THEN** the device pool preserves the existing compatible synchronization and ownership-conflict behavior
#### Scenario: Invalid snapshot is rejected atomically
- **WHEN** a Host Agent snapshot contains invalid identifiers, unowned cloud device IDs, conflicting driver metadata, statuses, or capability tags
- **THEN** the control plane rejects the snapshot without partially replacing the Host's previous pooled devices or heartbeat timestamp
## ADDED Requirements
### Requirement: Durable device enrollment identity is independent of pool presence
The cloud repository SHALL retain a Host-scoped device enrollment and its assigned `device_id` independently of whether the device appears in the Host's latest heartbeat snapshot.
#### Scenario: Enrolled device disconnects
- **WHEN** a Host submits a heartbeat that no longer includes a previously enrolled device
- **THEN** the pooled-device projection removes that device while its durable enrollment remains available for later idempotent re-enrollment
#### Scenario: Enrolled device reconnects
- **WHEN** the Host later enrolls or reports the same local device reference again
- **THEN** the control plane reuses the existing cloud `device_id`
### Requirement: Managed device identity cannot be claimed by another Host
The device pool SHALL derive managed device ownership from durable enrollment rather than accepting a caller-selected cloud device ID.
#### Scenario: Host reports another Host's managed device
- **WHEN** Host B includes a cloud device ID enrolled to Host A in its heartbeat
- **THEN** the control plane rejects Host B's snapshot and Host A retains ownership
#### Scenario: Prior Host becomes stale
- **WHEN** Host A becomes stale and Host B presents Host A's cloud device ID
- **THEN** the control plane still rejects implicit takeover because managed device transfer requires a future explicit operation
@@ -0,0 +1,64 @@
## ADDED Requirements
### Requirement: Host bootstrap uses a one-time enrollment credential
The Cloud Control Plane SHALL provide a Host enrollment operation that authenticates a configured bootstrap credential, generates the authoritative `host_id`, and binds it to an edge-generated long-lived Host credential without storing the plaintext Host credential.
#### Scenario: New edge instance enrolls
- **WHEN** an edge instance presents a valid unused enrollment token, a new instance identifier, and a high-entropy candidate Host credential
- **THEN** the control plane returns a generated `host_id` and durably stores only the credential digest and enrollment binding
#### Scenario: Enrollment token is invalid
- **WHEN** an edge instance presents an unknown enrollment token
- **THEN** the control plane rejects enrollment without creating a Host identity or consuming any configured token
#### Scenario: Enrollment token is reused by another instance
- **WHEN** a consumed enrollment token is presented with a different instance identifier
- **THEN** the control plane returns a conflict and preserves the original Host binding
### Requirement: Host enrollment is idempotent across response loss
The Host enrollment operation SHALL return the existing cloud Host identity when the same instance repeats enrollment with the same bootstrap-token binding and Host credential digest.
#### Scenario: Identical enrollment is retried
- **WHEN** an edge instance repeats a successful enrollment request after losing the response
- **THEN** the control plane returns the original `host_id` without creating another Host or rotating the submitted Host credential
#### Scenario: Instance retries with a different Host credential
- **WHEN** an enrolled instance repeats enrollment with a different candidate Host credential
- **THEN** the control plane rejects the request and leaves the original Host credential binding unchanged
### Requirement: Cloud assigns Host-scoped device identities
An authenticated Host SHALL enroll each local device through an opaque Host-scoped local reference, and the control plane SHALL generate and durably return the `device_id` used by scheduling, heartbeat, leases, and assignment execution.
#### Scenario: Host enrolls a local device
- **WHEN** an authenticated Host submits a previously unknown local device reference and valid driver metadata
- **THEN** the control plane creates a device enrollment owned by that Host and returns a generated `device_id`
#### Scenario: Device enrollment is repeated
- **WHEN** the same Host repeats enrollment for the same local device reference
- **THEN** the control plane returns the existing `device_id` and does not create a duplicate enrollment
#### Scenario: Same local reference appears on another Host
- **WHEN** a different Host enrolls an identical local device reference
- **THEN** the control plane creates a distinct Host-scoped device enrollment rather than silently transferring ownership
### Requirement: Edge identity and device mappings survive restart
The Host Agent SHALL persist its generated instance identifier, long-lived Host credential, assigned Host ID, and local-to-cloud device mappings outside process memory and SHALL reuse them on later starts.
#### Scenario: Host Agent restarts after enrollment
- **WHEN** a managed Host Agent restarts with intact identity state
- **THEN** it authenticates with the previously assigned `host_id` and credential without consuming another enrollment token
#### Scenario: Device mapping is missing but Host identity remains
- **WHEN** a managed Host Agent has its Host identity but lacks a cached mapping for a configured local device
- **THEN** it repeats idempotent device enrollment and restores the original cloud `device_id`
#### Scenario: Enrollment response is lost before Host ID persistence
- **WHEN** the Host Agent persisted its candidate credential but did not persist the successful response
- **THEN** its next start retries the identical enrollment request and recovers the original `host_id`
### Requirement: Enrolled Host credentials are revocable
The cloud repository SHALL support revoking a dynamically enrolled Host credential, and authentication SHALL reject revoked credentials without deleting task or attempt history.
#### Scenario: Revoked Host sends heartbeat
- **WHEN** a Host presents a credential whose enrollment has been revoked
- **THEN** the internal API rejects the request and preserves existing cloud history for that Host and its devices
@@ -0,0 +1,48 @@
## MODIFIED Requirements
### Requirement: Host identity is authenticated and bound to one host id
Except for the bootstrap enrollment operation authenticated by a configured one-time enrollment credential, the internal Host Agent API SHALL require a host-scoped bearer principal and SHALL reject any request that attempts to act for a `host_id` different from the authenticated principal's bound host.
#### Scenario: Host authenticates as itself
- **WHEN** a Host Agent presents valid static or dynamically enrolled credentials bound to its requested `host_id`
- **THEN** the internal API authorizes permitted device enrollment, heartbeat, claim, renewal, and result operations
#### Scenario: Host attempts to impersonate another host
- **WHEN** valid credentials bound to host A are used on a request for host B
- **THEN** the internal API rejects the request without reading or modifying host B's state
#### Scenario: Unknown Host credential is presented
- **WHEN** a caller presents a bearer credential that is neither statically configured nor bound to a non-revoked enrolled Host
- **THEN** the internal API rejects the request without exposing whether a Host ID exists
### Requirement: Host Agent synchronizes heartbeat and complete device snapshots
The Host Agent SHALL periodically submit its complete local device snapshot to the control plane, and the control plane SHALL atomically refresh the host heartbeat and replace only that host's pooled-device records. Enrollment-managed Hosts SHALL report the cloud device IDs assigned through device enrollment.
#### Scenario: Managed Host reports enrolled devices
- **WHEN** an enrollment-managed Host Agent submits a valid heartbeat containing cloud device IDs enrolled to that Host
- **THEN** the control plane updates the host's last-seen time and exposes the submitted devices through the aggregated pool
#### Scenario: Managed Host reports an unknown device ID
- **WHEN** an enrollment-managed Host reports a device ID not enrolled to that Host or a driver type that conflicts with its enrollment
- **THEN** the control plane rejects the entire snapshot without changing the previous heartbeat or pooled-device state
#### Scenario: Legacy Host reports devices
- **WHEN** a statically configured legacy Host submits a valid device snapshot
- **THEN** the control plane retains the existing compatible snapshot and ownership-conflict behavior
#### Scenario: Host reports no devices
- **WHEN** a previously populated Host submits an empty device snapshot
- **THEN** only that Host's prior pooled-device records are removed while durable enrollment records and devices owned by other Hosts remain unchanged
## ADDED Requirements
### Requirement: Managed Host startup enrolls before normal protocol loops
The Host Agent SHALL resolve its Host identity and cloud device mappings before starting heartbeat synchronization or assignment long-polling.
#### Scenario: New managed Host starts
- **WHEN** the Host Agent has an enrollment token but no completed local Host identity
- **THEN** it performs idempotent Host enrollment, enrolls configured local devices, constructs its `DeviceManager` with cloud device IDs, and only then starts heartbeat and claim loops
#### Scenario: Explicit legacy credentials are configured
- **WHEN** both `HOST_AGENT_HOST_ID` and `HOST_AGENT_TOKEN` are explicitly supplied
- **THEN** the Host Agent skips bootstrap enrollment and preserves existing local device ID behavior
@@ -0,0 +1,40 @@
## 1. Durable Enrollment Repository
- [x] 1.1 Add Host and device enrollment records plus repository protocol operations for atomic enrollment, credential lookup/revocation, device lookup, and managed-Host detection.
- [x] 1.2 Extend SQLAlchemy models and add Alembic revision 0002 with forward/downgrade support, indexes, uniqueness constraints, and the updated schema head.
- [x] 1.3 Implement atomic Host enrollment idempotency and one-time enrollment-token consumption in the SQL repository for SQLite and PostgreSQL.
- [x] 1.4 Implement dynamic credential lookup/revocation and idempotent Host-scoped device enrollment in the SQL repository.
- [x] 1.5 Add repository contract and migration tests covering success, retries, token reuse conflicts, revocation, per-Host device identity, and schema preservation.
## 2. Cloud Authentication And Internal API
- [x] 2.1 Parse and validate `CLOUD_ENROLLMENT_TOKENS_JSON` without exposing token values, while retaining existing configured bearer credentials.
- [x] 2.2 Add enrollment-token verification, repository-backed dynamic Host authentication, and composed authentication providers.
- [x] 2.3 Add Host/device enrollment request-response models and authenticated internal API routes with generated cloud IDs and conflict handling.
- [x] 2.4 Require enrollment-managed Host heartbeat snapshots to contain only enrolled, non-revoked device IDs with matching driver types while preserving legacy snapshot behavior.
- [x] 2.5 Compose enrollment services into the deployable Cloud API and add HTTP tests for bootstrap, idempotency, authorization, device enrollment, heartbeat validation, restart authentication, and public-scope isolation.
## 3. Edge Identity And Device Mapping Storage
- [x] 3.1 Add an atomic, restrictive-permission Host identity store that persists pending and completed enrollment state across response loss and restart.
- [x] 3.2 Extend Host Agent configuration to support explicit legacy credentials or managed enrollment with configurable token and identity-state path.
- [x] 3.3 Extend `DeviceConfigStore` with nullable cloud device mappings and backward-compatible schema upgrade/read/write operations.
- [x] 3.4 Add focused tests for identity-state recovery, configuration mode validation, file secrecy behavior, and device mapping persistence.
## 4. Host Agent Enrollment Startup
- [x] 4.1 Extend the Host Agent client with synchronous bootstrap/device-enrollment operations that preserve existing retry and typed-error behavior.
- [x] 4.2 Resolve managed Host identity before application construction, persisting the candidate secret before the request and assigned Host ID after success.
- [x] 4.3 Enroll configured local devices before building the managed `DeviceManager`, persist mappings, and register drivers under cloud device IDs while leaving legacy mode unchanged.
- [x] 4.4 Update Host Agent application and heartbeat/assignment tests for first enrollment, response-loss retry, restart reuse, device mapping recovery, legacy compatibility, and startup ordering.
## 5. Deployment And Operations
- [x] 5.1 Add enrollment-token and identity-path variables to example/Compose deployment configuration without overwriting existing registry/image customizations.
- [x] 5.2 Update cloud and macOS edge deployment documentation for managed enrollment, secret/state handling, static fallback, revocation, migration, rollback, and outbound-only networking.
## 6. Verification
- [x] 6.1 Run formatting/static checks and focused repository, migration, Cloud API, Host Agent, deployment-contract, and end-to-end tests.
- [x] 6.2 Run the complete non-integration workspace test suite and resolve regressions without modifying unrelated cloud-console work.
- [x] 6.3 Run strict OpenSpec validation, review the final diff for credential leakage and architecture-boundary violations, and record verified limitations.
+82 -1
View File
@@ -4,7 +4,10 @@ from dataclasses import dataclass, field
from hashlib import sha256
from hmac import compare_digest
from collections.abc import Iterable
from typing import Protocol, runtime_checkable
from typing import TYPE_CHECKING, Protocol, runtime_checkable
if TYPE_CHECKING:
from cloud.repository import CloudRepository
TASKS_SUBMIT_SCOPE = "tasks:submit"
@@ -78,6 +81,24 @@ class _StoredBearerCredential:
token_digest: bytes = field(repr=False)
@dataclass(frozen=True)
class EnrollmentCredential:
principal_id: str
token: str = field(repr=False)
def __post_init__(self) -> None:
if not self.principal_id.strip():
raise ValueError("enrollment credential principal_id must not be empty")
if not self.token:
raise ValueError("enrollment credential token must not be empty")
@dataclass(frozen=True)
class EnrollmentPrincipal:
id: str
token_digest: str = field(repr=False)
class ConfiguredBearerAuthProvider:
"""Authenticate configured bearer tokens without exposing credential text."""
@@ -107,6 +128,57 @@ class ConfiguredBearerAuthProvider:
return matched_principal
class ConfiguredEnrollmentTokenProvider:
def __init__(self, credentials: Iterable[EnrollmentCredential]) -> None:
self._credentials = tuple(
EnrollmentPrincipal(
id=credential.principal_id,
token_digest=digest_token(credential.token),
)
for credential in credentials
)
def authenticate(self, request: object) -> EnrollmentPrincipal | None:
candidate = bearer_token_digest(request)
if candidate is None:
return None
candidate_bytes = bytes.fromhex(candidate)
matched: EnrollmentPrincipal | None = None
for credential in self._credentials:
if compare_digest(
candidate_bytes,
bytes.fromhex(credential.token_digest),
):
matched = credential
return matched
class RepositoryHostAuthProvider:
def __init__(self, repository: CloudRepository) -> None:
self.repository = repository
def authenticate(self, request: object) -> Principal | None:
credential_digest = bearer_token_digest(request)
if credential_digest is None:
return None
host_id = self.repository.authenticate_enrolled_host(credential_digest)
if host_id is None:
return None
return Principal(id=f"enrolled-host:{host_id}", host_id=host_id)
class ChainedAuthProvider:
def __init__(self, providers: Iterable[AuthProvider]) -> None:
self.providers = tuple(providers)
def authenticate(self, request: object) -> Principal | None:
for provider in self.providers:
principal = provider.authenticate(request)
if principal is not None:
return principal
return None
def create_auth_provider(
credentials: Iterable[BearerCredential],
*,
@@ -133,5 +205,14 @@ def _extract_bearer_token(request: object) -> str | None:
return token
def bearer_token_digest(request: object) -> str | None:
token = _extract_bearer_token(request)
return digest_token(token) if token is not None else None
def digest_token(token: str) -> str:
return _token_digest(token).hex()
def _token_digest(token: str) -> bytes:
return sha256(token.encode("utf-8")).digest()
@@ -6,7 +6,7 @@ from collections.abc import Mapping
from dataclasses import dataclass
from typing import Literal
from cloud.auth import BearerCredential
from cloud.auth import BearerCredential, EnrollmentCredential
EnvironmentName = Literal["local", "test", "production"]
@@ -31,6 +31,8 @@ class CloudControlConfig:
max_task_attempts: int = 3
allow_insecure_anonymous: bool = False
credentials: tuple[BearerCredential, ...] = ()
enrollment_credentials: tuple[EnrollmentCredential, ...] = ()
cors_allowed_origins: tuple[str, ...] = ()
def load_control_config(
@@ -86,6 +88,12 @@ def load_control_config(
require_host_id=True,
),
),
enrollment_credentials=_parse_enrollment_credentials(
values.get("CLOUD_ENROLLMENT_TOKENS_JSON")
),
cors_allowed_origins=_parse_cors_origins(
values.get("CLOUD_CONSOLE_CORS_ORIGINS")
),
)
validate_control_config(config)
return config
@@ -145,6 +153,33 @@ def _parse_credentials(
) from exc
def _parse_enrollment_credentials(
raw_value: str | None,
) -> tuple[EnrollmentCredential, ...]:
if raw_value is None or not raw_value.strip():
return ()
try:
payload = json.loads(raw_value)
if not isinstance(payload, list):
raise TypeError
credentials: list[EnrollmentCredential] = []
for item in payload:
if not isinstance(item, dict):
raise TypeError
principal_id = item.get("principal_id")
token = item.get("token")
if not isinstance(principal_id, str) or not isinstance(token, str):
raise TypeError
credentials.append(
EnrollmentCredential(principal_id=principal_id, token=token)
)
return tuple(credentials)
except (TypeError, ValueError, json.JSONDecodeError) as exc:
raise CloudConfigurationError(
"configured enrollment credentials are invalid"
) from exc
def _positive_float(
values: Mapping[str, str],
name: str,
@@ -188,3 +223,9 @@ def _parse_bool(value: str | None, *, default: bool) -> bool:
if normalized in {"0", "false", "no", "off", "disabled", ""}:
return False
raise CloudConfigurationError("boolean configuration value is invalid")
def _parse_cors_origins(raw_value: str | None) -> tuple[str, ...]:
if raw_value is None or not raw_value.strip():
return ()
return tuple(origin.strip() for origin in raw_value.split(",") if origin.strip())
+51 -1
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from sqlalchemy import Integer, String, Text, text
from sqlalchemy import Index, Integer, String, Text, UniqueConstraint, text
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
@@ -10,10 +10,60 @@ class Base(DeclarativeBase):
class HostRow(Base):
__tablename__ = "host_registrations"
__table_args__ = (
UniqueConstraint(
"agent_instance_id",
name="uq_host_registrations_agent_instance_id",
),
UniqueConstraint(
"credential_digest",
name="uq_host_registrations_credential_digest",
),
UniqueConstraint(
"enrollment_token_digest",
name="uq_host_registrations_enrollment_token_digest",
),
)
host_id: Mapped[str] = mapped_column(String, primary_key=True)
address: Mapped[str | None] = mapped_column(String, nullable=True)
last_seen_at: Mapped[str] = mapped_column(String, nullable=False)
agent_instance_id: Mapped[str | None] = mapped_column(
String,
nullable=True,
)
credential_digest: Mapped[str | None] = mapped_column(
String,
nullable=True,
)
enrollment_token_digest: Mapped[str | None] = mapped_column(
String,
nullable=True,
)
display_name: Mapped[str | None] = mapped_column(String, nullable=True)
enrolled_at: Mapped[str | None] = mapped_column(String, nullable=True)
revoked_at: Mapped[str | None] = mapped_column(String, nullable=True)
class DeviceEnrollmentRow(Base):
__tablename__ = "device_enrollments"
__table_args__ = (
UniqueConstraint(
"host_id",
"local_device_id",
name="uq_device_enrollments_host_local",
),
Index("ix_device_enrollments_host_id", "host_id"),
)
device_id: Mapped[str] = mapped_column(String, primary_key=True)
host_id: Mapped[str] = mapped_column(String, nullable=False)
local_device_id: Mapped[str] = mapped_column(String, nullable=False)
driver_type: Mapped[str] = mapped_column(String, nullable=False)
name: Mapped[str | None] = mapped_column(String, nullable=True)
capability_tags_json: Mapped[str] = mapped_column(Text, nullable=False)
enrolled_at: Mapped[str] = mapped_column(String, nullable=False)
revoked_at: Mapped[str | None] = mapped_column(String, nullable=True)
class PooledDeviceRow(Base):
@@ -5,26 +5,38 @@ from collections.abc import Awaitable, Callable
from datetime import timedelta
from time import monotonic
from typing import TYPE_CHECKING
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Request, status
from fastapi.responses import JSONResponse
from cloud.auth import (
AuthProvider,
ConfiguredEnrollmentTokenProvider,
HostAuthorizationError,
digest_token,
)
from cloud.internal_api.models import (
AssignmentModel,
ClaimRequest,
ClaimResponse,
DeviceEnrollmentRequest,
DeviceEnrollmentResponse,
HeartbeatRequest,
HeartbeatResponse,
HostEnrollmentRequest,
HostEnrollmentResponse,
LeaseRenewalRequest,
LeaseRenewalResponse,
StaleLeaseConflict,
TerminalResultRequest,
TerminalResultResponse,
)
from cloud.repository import (
DeviceEnrollmentConflictError,
EnrollmentTokenConflictError,
HostEnrollmentConflictError,
)
from core.models import Device, utc_now
if TYPE_CHECKING:
@@ -35,6 +47,7 @@ def create_internal_router(
*,
pool: DevicePool,
auth_provider: AuthProvider,
enrollment_auth_provider: ConfiguredEnrollmentTokenProvider | None = None,
version_prefix: str = "/internal/v1",
claim_poll_interval_seconds: float = 0.1,
lease_duration_seconds: float = 60.0,
@@ -45,6 +58,7 @@ def create_internal_router(
if lease_duration_seconds <= 0:
raise ValueError("lease_duration_seconds must be greater than zero")
router = APIRouter(prefix=version_prefix, tags=["host-agent"])
enrollment_auth = enrollment_auth_provider or ConfiguredEnrollmentTokenProvider(())
def authorize_host(request: Request, host_id: str) -> None:
principal = auth_provider.authenticate(request)
@@ -62,6 +76,66 @@ def create_internal_router(
detail=str(exc),
) from exc
@router.post(
"/enrollments",
response_model=HostEnrollmentResponse,
status_code=status.HTTP_201_CREATED,
)
def enroll_host(
payload: HostEnrollmentRequest,
request: Request,
) -> HostEnrollmentResponse:
enrollment_principal = enrollment_auth.authenticate(request)
if enrollment_principal is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="unauthorized",
headers={"WWW-Authenticate": "Bearer"},
)
try:
enrollment = pool.store.enroll_host(
host_id=f"host-{uuid4().hex}",
agent_instance_id=payload.agent_instance_id,
credential_digest=digest_token(payload.host_token),
enrollment_token_digest=enrollment_principal.token_digest,
display_name=payload.display_name,
enrolled_at=utc_now(),
)
except (EnrollmentTokenConflictError, HostEnrollmentConflictError) as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
return HostEnrollmentResponse(host_id=enrollment.host_id)
@router.post(
"/hosts/{host_id}/devices/enroll",
response_model=DeviceEnrollmentResponse,
status_code=status.HTTP_201_CREATED,
)
def enroll_device(
host_id: str,
payload: DeviceEnrollmentRequest,
request: Request,
) -> DeviceEnrollmentResponse:
authorize_host(request, host_id)
try:
enrollment = pool.store.enroll_device(
device_id=f"device-{uuid4().hex}",
host_id=host_id,
local_device_id=payload.local_device_id,
driver_type=payload.driver_type,
name=payload.name,
capability_tags=list(payload.capability_tags),
enrolled_at=utc_now(),
)
except DeviceEnrollmentConflictError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
return DeviceEnrollmentResponse(device_id=enrollment.device_id)
@router.put(
"/hosts/{host_id}/heartbeat",
response_model=HeartbeatResponse,
@@ -250,6 +324,25 @@ def _validate_snapshot(
detail="heartbeat snapshot contains duplicate device ids",
)
if pool.store.is_enrollment_managed_host(host_id):
enrollments = {
enrollment.device_id: enrollment
for enrollment in pool.store.list_device_enrollments(host_id)
if enrollment.revoked_at is None
}
invalid = [
device.device_id
for device in payload.devices
if device.device_id not in enrollments
or enrollments[device.device_id].driver_type != device.driver_type
]
if invalid:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"device enrollment conflict: {sorted(set(invalid))}",
)
return False
now = utc_now()
hosts = {host.host_id: host for host in pool.store.list_hosts()}
conflicts: list[str] = []
@@ -6,6 +6,27 @@ from typing import Any, Literal
from pydantic import BaseModel, Field
class HostEnrollmentRequest(BaseModel):
agent_instance_id: str = Field(min_length=1, max_length=256)
host_token: str = Field(min_length=32, max_length=512)
display_name: str | None = Field(default=None, max_length=256)
class HostEnrollmentResponse(BaseModel):
host_id: str
class DeviceEnrollmentRequest(BaseModel):
local_device_id: str = Field(min_length=1, max_length=256)
driver_type: str = Field(min_length=1, max_length=128)
name: str | None = Field(default=None, max_length=256)
capability_tags: list[str] = Field(default_factory=list)
class DeviceEnrollmentResponse(BaseModel):
device_id: str
class DeviceSnapshotModel(BaseModel):
device_id: str = Field(min_length=1)
driver_type: str = Field(min_length=1)
@@ -0,0 +1,96 @@
"""Add durable Host and device enrollment identity."""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "0002_edge_host_enrollment"
down_revision = "0001_cloud_repository"
branch_labels = None
depends_on = None
HOST_ENROLLMENT_COLUMNS = (
sa.Column("agent_instance_id", sa.String(), nullable=True),
sa.Column("credential_digest", sa.String(), nullable=True),
sa.Column("enrollment_token_digest", sa.String(), nullable=True),
sa.Column("display_name", sa.String(), nullable=True),
sa.Column("enrolled_at", sa.String(), nullable=True),
sa.Column("revoked_at", sa.String(), nullable=True),
)
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
existing_columns = {
column["name"] for column in inspector.get_columns("host_registrations")
}
with op.batch_alter_table("host_registrations") as batch:
for column in HOST_ENROLLMENT_COLUMNS:
if column.name not in existing_columns:
batch.add_column(column)
inspector = sa.inspect(op.get_bind())
indexes = {index["name"] for index in inspector.get_indexes("host_registrations")}
with op.batch_alter_table("host_registrations") as batch:
for name, column in (
("uq_host_registrations_agent_instance_id", "agent_instance_id"),
("uq_host_registrations_credential_digest", "credential_digest"),
(
"uq_host_registrations_enrollment_token_digest",
"enrollment_token_digest",
),
):
if name not in indexes:
batch.create_unique_constraint(name, [column])
inspector = sa.inspect(op.get_bind())
if "device_enrollments" not in inspector.get_table_names():
op.create_table(
"device_enrollments",
sa.Column("device_id", sa.String(), primary_key=True),
sa.Column("host_id", sa.String(), nullable=False),
sa.Column("local_device_id", sa.String(), nullable=False),
sa.Column("driver_type", sa.String(), nullable=False),
sa.Column("name", sa.String(), nullable=True),
sa.Column("capability_tags_json", sa.Text(), nullable=False),
sa.Column("enrolled_at", sa.String(), nullable=False),
sa.Column("revoked_at", sa.String(), nullable=True),
sa.UniqueConstraint(
"host_id",
"local_device_id",
name="uq_device_enrollments_host_local",
),
)
op.create_index(
"ix_device_enrollments_host_id",
"device_enrollments",
["host_id"],
)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
if "device_enrollments" in inspector.get_table_names():
op.drop_index(
"ix_device_enrollments_host_id",
table_name="device_enrollments",
)
op.drop_table("device_enrollments")
inspector = sa.inspect(op.get_bind())
existing_columns = {
column["name"] for column in inspector.get_columns("host_registrations")
}
with op.batch_alter_table("host_registrations") as batch:
for name in (
"uq_host_registrations_enrollment_token_digest",
"uq_host_registrations_credential_digest",
"uq_host_registrations_agent_instance_id",
):
batch.drop_constraint(name, type_="unique")
for column in reversed(HOST_ENROLLMENT_COLUMNS):
if column.name in existing_columns:
batch.drop_column(column.name)
+91 -1
View File
@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any, Literal, Protocol
if TYPE_CHECKING:
from cloud.plugins import PluginManifest
from cloud.pool import HostRegistration, PooledDevice
from cloud.scheduler import ScheduledTask
from cloud.scheduler import ScheduledTask, ScheduledTaskStatus
AttemptStatus = Literal["assigned", "dispatched", "done", "failed", "expired"]
@@ -16,6 +16,39 @@ ResultRecordStatus = Literal["recorded", "already_recorded", "conflict"]
LeaseRenewalStatus = Literal["renewed", "not_found", "conflict", "expired"]
class HostEnrollmentConflictError(RuntimeError):
"""Raised when a Host enrollment cannot preserve its existing binding."""
class EnrollmentTokenConflictError(HostEnrollmentConflictError):
"""Raised when a one-time enrollment token is already bound elsewhere."""
class DeviceEnrollmentConflictError(RuntimeError):
"""Raised when a local device enrollment conflicts with stored identity."""
@dataclass(frozen=True)
class HostEnrollment:
host_id: str
agent_instance_id: str
display_name: str | None
enrolled_at: datetime
revoked_at: datetime | None = None
@dataclass(frozen=True)
class DeviceEnrollment:
device_id: str
host_id: str
local_device_id: str
driver_type: str
name: str | None
capability_tags: list[str]
enrolled_at: datetime
revoked_at: datetime | None = None
@dataclass(frozen=True)
class TaskAttemptRecord:
task_id: str
@@ -46,6 +79,53 @@ class LeasedAssignment:
class CloudRepository(Protocol):
"""Persistence port for cloud state and atomic scheduling operations."""
def enroll_host(
self,
*,
host_id: str,
agent_instance_id: str,
credential_digest: str,
enrollment_token_digest: str,
display_name: str | None,
enrolled_at: datetime,
) -> HostEnrollment: ...
def authenticate_enrolled_host(
self,
credential_digest: str,
) -> str | None: ...
def revoke_enrolled_host(
self,
host_id: str,
*,
revoked_at: datetime,
) -> bool: ...
def is_enrollment_managed_host(self, host_id: str) -> bool: ...
def enroll_device(
self,
*,
device_id: str,
host_id: str,
local_device_id: str,
driver_type: str,
name: str | None,
capability_tags: list[str],
enrolled_at: datetime,
) -> DeviceEnrollment: ...
def get_device_enrollment(
self,
device_id: str,
) -> DeviceEnrollment | None: ...
def list_device_enrollments(
self,
host_id: str,
) -> list[DeviceEnrollment]: ...
def upsert_host(
self,
host_id: str,
@@ -74,6 +154,16 @@ class CloudRepository(Protocol):
def list_queued_tasks(self) -> list[ScheduledTask]: ...
def list_tasks(
self,
*,
status: ScheduledTaskStatus | None = None,
limit: int = 50,
offset: int = 0,
) -> list[ScheduledTask]: ...
def count_tasks(self, status: ScheduledTaskStatus | None = None) -> int: ...
def get_task(self, task_id: str) -> ScheduledTask | None: ...
def update_task(
+1 -1
View File
@@ -9,7 +9,7 @@ from alembic.runtime.migration import MigrationContext
from cloud.database import create_database_engine, normalize_database_url
HEAD_REVISION = "0001_cloud_repository"
HEAD_REVISION = "0002_edge_host_enrollment"
class SchemaVersionError(RuntimeError):
+75 -2
View File
@@ -10,7 +10,7 @@ authentication can be added later without changing route signatures.
from __future__ import annotations
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Literal
from cloud.auth import (
PLUGINS_ADMIN_SCOPE,
@@ -28,11 +28,14 @@ from cloud.sdk.models import (
HostResponse,
PluginRegistrationRequest,
PluginResponse,
TaskAttemptResponse,
TaskListItem,
TaskListResponse,
TaskStatusResponse,
TaskSubmissionRequest,
TaskSubmissionResponse,
)
from fastapi import APIRouter, HTTPException, Request, status
from fastapi import APIRouter, HTTPException, Query, Request, status
if TYPE_CHECKING:
from cloud.plugins import PluginRegistry
@@ -112,6 +115,76 @@ def create_cloud_router(
failure_reason=task.failure_reason,
)
@router.get("/tasks", response_model=TaskListResponse)
def list_tasks(
request: Request,
status_filter: Literal[
"queued", "assigned", "dispatched", "done", "failed"
]
| None = Query(default=None, alias="status"),
limit: int = Query(default=50, ge=1, le=100),
offset: int = Query(default=0, ge=0),
) -> TaskListResponse:
_authorize(request, TASKS_READ_SCOPE)
tasks = scheduler.store.list_tasks(
status=status_filter,
limit=limit,
offset=offset,
)
total = scheduler.store.count_tasks(status=status_filter)
return TaskListResponse(
items=[
TaskListItem(
id=task.id,
status=task.status,
goal=task.goal,
workflow_definition_id=task.workflow_definition_id,
assigned_device_id=task.assigned_device_id,
assigned_host_id=task.assigned_host_id,
attempt_count=task.attempt_count,
failure_reason=task.failure_reason,
created_at=task.created_at,
)
for task in tasks
],
total=total,
limit=limit,
offset=offset,
)
@router.get(
"/tasks/{task_id}/attempts",
response_model=list[TaskAttemptResponse],
)
def list_task_attempts(
task_id: str,
request: Request,
) -> list[TaskAttemptResponse]:
_authorize(request, TASKS_READ_SCOPE)
task = scheduler.store.get_task(task_id)
if task is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"task {task_id!r} not found",
)
attempts = scheduler.store.list_task_attempts(task_id)
return [
TaskAttemptResponse(
task_id=attempt.task_id,
attempt=attempt.attempt,
lease_id=attempt.lease_id,
host_id=attempt.host_id,
device_id=attempt.device_id,
status=attempt.status,
lease_expires_at=attempt.lease_expires_at,
created_at=attempt.created_at,
completed_at=attempt.completed_at,
failure_reason=attempt.failure_reason,
terminal_result=attempt.terminal_result,
)
for attempt in attempts
]
@router.get("/devices", response_model=list[DeviceResponse])
def list_devices(request: Request) -> list[DeviceResponse]:
_authorize(request, POOL_READ_SCOPE)
@@ -89,6 +89,23 @@ class CloudClient:
resp = self._request("GET", f"/tasks/{task_id}")
return resp.json()
def list_tasks(
self,
*,
status: str | None = None,
limit: int = 50,
offset: int = 0,
) -> dict[str, Any]:
params: dict[str, Any] = {"limit": limit, "offset": offset}
if status is not None:
params["status"] = status
resp = self._request("GET", "/tasks", params=params)
return resp.json()
def get_task_attempts(self, task_id: str) -> list[dict[str, Any]]:
resp = self._request("GET", f"/tasks/{task_id}/attempts")
return resp.json()
# ----------------------------------------------------------------- devices
def list_devices(self) -> list[dict[str, Any]]:
@@ -133,11 +150,13 @@ class CloudClient:
path: str,
*,
json: dict[str, Any] | None = None,
params: dict[str, Any] | None = None,
) -> httpx.Response:
response = self._http.request(
method,
self._url(path),
json=json,
params=params,
headers=self._headers,
auth=self._auth,
)
+34 -1
View File
@@ -3,7 +3,7 @@
from __future__ import annotations
from datetime import datetime
from typing import Literal
from typing import Any, Literal
from pydantic import BaseModel, Field
@@ -35,6 +35,39 @@ class TaskStatusResponse(BaseModel):
failure_reason: str | None = None
class TaskListItem(BaseModel):
id: str
status: str
goal: str | None = None
workflow_definition_id: str | None = None
assigned_device_id: str | None = None
assigned_host_id: str | None = None
attempt_count: int = 0
failure_reason: str | None = None
created_at: datetime
class TaskListResponse(BaseModel):
items: list[TaskListItem]
total: int
limit: int
offset: int
class TaskAttemptResponse(BaseModel):
task_id: str
attempt: int
lease_id: str
host_id: str
device_id: str
status: str
lease_expires_at: datetime
created_at: datetime
completed_at: datetime | None = None
failure_reason: str | None = None
terminal_result: dict[str, Any] | None = None
class DeviceResponse(BaseModel):
device_id: str
host_id: str
@@ -7,10 +7,12 @@ from datetime import datetime
from typing import Any
from sqlalchemy import Engine, delete, func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import sessionmaker
from cloud.db_models import (
Base,
DeviceEnrollmentRow,
HostRow,
PluginRow,
PooledDeviceRow,
@@ -33,6 +35,211 @@ class SQLAlchemyCloudRepository:
if create_schema:
Base.metadata.create_all(engine)
def enroll_host(
self,
*,
host_id: str,
agent_instance_id: str,
credential_digest: str,
enrollment_token_digest: str,
display_name: str | None,
enrolled_at: datetime,
) -> Any:
from cloud.repository import (
EnrollmentTokenConflictError,
HostEnrollmentConflictError,
)
try:
with self._sessions.begin() as session:
statement = select(HostRow).where(
HostRow.agent_instance_id == agent_instance_id
)
if self.engine.dialect.name == "postgresql":
statement = statement.with_for_update()
existing = session.scalars(statement).first()
if existing is not None:
if (
existing.credential_digest != credential_digest
or existing.enrollment_token_digest != enrollment_token_digest
):
raise HostEnrollmentConflictError(
"Host enrollment identity does not match existing binding"
)
return _host_enrollment_from_row(existing)
token_owner = session.scalars(
select(HostRow).where(
HostRow.enrollment_token_digest == enrollment_token_digest
)
).first()
if token_owner is not None:
raise EnrollmentTokenConflictError(
"enrollment token is already bound to another Host"
)
row = HostRow(
host_id=host_id,
address=None,
last_seen_at=_iso(enrolled_at),
agent_instance_id=agent_instance_id,
credential_digest=credential_digest,
enrollment_token_digest=enrollment_token_digest,
display_name=display_name,
enrolled_at=_iso(enrolled_at),
revoked_at=None,
)
session.add(row)
session.flush()
return _host_enrollment_from_row(row)
except IntegrityError as exc:
with self._sessions() as session:
existing = session.scalars(
select(HostRow).where(
HostRow.agent_instance_id == agent_instance_id
)
).first()
if (
existing is not None
and existing.credential_digest == credential_digest
and existing.enrollment_token_digest == enrollment_token_digest
):
return _host_enrollment_from_row(existing)
token_owner = session.scalars(
select(HostRow).where(
HostRow.enrollment_token_digest == enrollment_token_digest
)
).first()
if token_owner is not None:
raise EnrollmentTokenConflictError(
"enrollment token is already bound to another Host"
) from exc
raise HostEnrollmentConflictError(
"Host enrollment conflicts with an existing identity"
) from exc
def authenticate_enrolled_host(self, credential_digest: str) -> str | None:
with self._sessions() as session:
host_id = session.scalar(
select(HostRow.host_id)
.where(
HostRow.credential_digest == credential_digest,
HostRow.revoked_at.is_(None),
)
.limit(1)
)
return str(host_id) if host_id is not None else None
def revoke_enrolled_host(
self,
host_id: str,
*,
revoked_at: datetime,
) -> bool:
with self._sessions.begin() as session:
row = session.get(
HostRow,
host_id,
with_for_update=self.engine.dialect.name == "postgresql",
)
if row is None or row.credential_digest is None:
return False
row.revoked_at = _iso(revoked_at)
return True
def is_enrollment_managed_host(self, host_id: str) -> bool:
with self._sessions() as session:
credential_digest = session.scalar(
select(HostRow.credential_digest).where(HostRow.host_id == host_id)
)
return credential_digest is not None
def enroll_device(
self,
*,
device_id: str,
host_id: str,
local_device_id: str,
driver_type: str,
name: str | None,
capability_tags: list[str],
enrolled_at: datetime,
) -> Any:
from cloud.repository import DeviceEnrollmentConflictError
tags_json = json.dumps(list(capability_tags), ensure_ascii=False)
try:
with self._sessions.begin() as session:
statement = select(DeviceEnrollmentRow).where(
DeviceEnrollmentRow.host_id == host_id,
DeviceEnrollmentRow.local_device_id == local_device_id,
)
if self.engine.dialect.name == "postgresql":
statement = statement.with_for_update()
existing = session.scalars(statement).first()
if existing is not None:
if existing.driver_type != driver_type:
raise DeviceEnrollmentConflictError(
"device driver type does not match existing enrollment"
)
if existing.revoked_at is not None:
raise DeviceEnrollmentConflictError(
"device enrollment has been revoked"
)
existing.name = name
existing.capability_tags_json = tags_json
return _device_enrollment_from_row(existing)
host = session.get(HostRow, host_id)
if host is None:
session.add(
HostRow(
host_id=host_id,
address=None,
last_seen_at=_iso(enrolled_at),
)
)
row = DeviceEnrollmentRow(
device_id=device_id,
host_id=host_id,
local_device_id=local_device_id,
driver_type=driver_type,
name=name,
capability_tags_json=tags_json,
enrolled_at=_iso(enrolled_at),
revoked_at=None,
)
session.add(row)
session.flush()
return _device_enrollment_from_row(row)
except IntegrityError as exc:
with self._sessions() as session:
existing = session.scalars(
select(DeviceEnrollmentRow).where(
DeviceEnrollmentRow.host_id == host_id,
DeviceEnrollmentRow.local_device_id == local_device_id,
)
).first()
if existing is not None and existing.driver_type == driver_type:
return _device_enrollment_from_row(existing)
raise DeviceEnrollmentConflictError(
"device enrollment conflicts with an existing identity"
) from exc
def get_device_enrollment(self, device_id: str) -> Any | None:
with self._sessions() as session:
row = session.get(DeviceEnrollmentRow, device_id)
return _device_enrollment_from_row(row) if row else None
def list_device_enrollments(self, host_id: str) -> list[Any]:
with self._sessions() as session:
rows = session.scalars(
select(DeviceEnrollmentRow)
.where(DeviceEnrollmentRow.host_id == host_id)
.order_by(DeviceEnrollmentRow.device_id)
).all()
return [_device_enrollment_from_row(row) for row in rows]
def upsert_host(
self,
host_id: str,
@@ -161,6 +368,36 @@ class SQLAlchemyCloudRepository:
).all()
return [_task_from_row(row) for row in rows]
def list_tasks(
self,
*,
status: str | None = None,
limit: int = 50,
offset: int = 0,
) -> list[Any]:
with self._sessions() as session:
statement = select(ScheduledTaskRow)
if status is not None:
statement = statement.where(ScheduledTaskRow.status == status)
statement = (
statement.order_by(
ScheduledTaskRow.created_at.desc(),
ScheduledTaskRow.id.desc(),
)
.limit(limit)
.offset(offset)
)
rows = session.scalars(statement).all()
return [_task_from_row(row) for row in rows]
def count_tasks(self, status: str | None = None) -> int:
with self._sessions() as session:
statement = select(func.count()).select_from(ScheduledTaskRow)
if status is not None:
statement = statement.where(ScheduledTaskRow.status == status)
count = session.scalar(statement)
return int(count or 0)
def get_task(self, task_id: str) -> Any | None:
with self._sessions() as session:
row = session.get(ScheduledTaskRow, task_id)
@@ -568,6 +805,40 @@ def _host_from_row(row: HostRow) -> Any:
)
def _host_enrollment_from_row(row: HostRow) -> Any:
from cloud.repository import HostEnrollment
enrolled_at = _parse_dt(row.enrolled_at) or _parse_dt(row.last_seen_at) or utc_now()
if row.agent_instance_id is None:
raise ValueError(f"Host {row.host_id!r} is not enrollment-managed")
return HostEnrollment(
host_id=row.host_id,
agent_instance_id=row.agent_instance_id,
display_name=row.display_name,
enrolled_at=enrolled_at,
revoked_at=_parse_dt(row.revoked_at),
)
def _device_enrollment_from_row(row: DeviceEnrollmentRow) -> Any:
from cloud.repository import DeviceEnrollment
try:
tags = list(json.loads(row.capability_tags_json))
except TypeError, ValueError:
tags = []
return DeviceEnrollment(
device_id=row.device_id,
host_id=row.host_id,
local_device_id=row.local_device_id,
driver_type=row.driver_type,
name=row.name,
capability_tags=tags,
enrolled_at=_parse_dt(row.enrolled_at) or utc_now(),
revoked_at=_parse_dt(row.revoked_at),
)
def _device_from_row(row: PooledDeviceRow) -> Any:
from cloud.pool import PooledDevice
+34 -4
View File
@@ -28,22 +28,42 @@ class DeviceConfigStore:
name: str | None = None,
driver_type: str,
connection_info: dict[str, Any],
cloud_device_id: str | None = None,
) -> None:
with self._connect() as connection:
connection.execute(
"""
insert or replace into device_configs (
device_id, name, driver_type, connection_info
) values (?, ?, ?, ?)
insert into device_configs (
device_id, name, driver_type, connection_info, cloud_device_id
) values (?, ?, ?, ?, ?)
on conflict(device_id) do update set
name = excluded.name,
driver_type = excluded.driver_type,
connection_info = excluded.connection_info,
cloud_device_id = coalesce(
excluded.cloud_device_id,
device_configs.cloud_device_id
)
""",
(
device_id,
name,
driver_type,
json.dumps(connection_info, ensure_ascii=False),
cloud_device_id,
),
)
def set_cloud_device_id(self, device_id: str, cloud_device_id: str) -> bool:
if not cloud_device_id:
raise ValueError("cloud_device_id must not be empty")
with self._connect() as connection:
cursor = connection.execute(
"update device_configs set cloud_device_id = ? where device_id = ?",
(cloud_device_id, device_id),
)
return cursor.rowcount > 0
def remove(self, device_id: str) -> None:
with self._connect() as connection:
connection.execute(
@@ -93,10 +113,19 @@ class DeviceConfigStore:
device_id text primary key,
name text,
driver_type text not null,
connection_info text not null
connection_info text not null,
cloud_device_id text
)
"""
)
columns = {
row["name"]
for row in connection.execute("pragma table_info(device_configs)")
}
if "cloud_device_id" not in columns:
connection.execute(
"alter table device_configs add column cloud_device_id text"
)
connection.execute(
"""
create table if not exists settings (
@@ -121,4 +150,5 @@ class DeviceConfigStore:
"name": row["name"],
"driver_type": row["driver_type"],
"connection_info": json.loads(row["connection_info"]),
"cloud_device_id": row["cloud_device_id"],
}
+51
View File
@@ -6,10 +6,15 @@ import cloud.auth as auth_module
import pytest
from cloud.auth import (
BearerCredential,
ChainedAuthProvider,
ConfiguredBearerAuthProvider,
ConfiguredEnrollmentTokenProvider,
EnrollmentCredential,
HostIdentityMismatchError,
HostPrincipalRequiredError,
NullAuthProvider,
RepositoryHostAuthProvider,
digest_token,
)
@@ -162,3 +167,49 @@ def test_authentication_failure_does_not_log_bearer_secret(caplog) -> None:
)
assert "invalid-secret" not in caplog.text
assert "valid-secret" not in caplog.text
def test_enrollment_token_provider_returns_digest_without_exposing_secret() -> None:
credential = EnrollmentCredential(
principal_id="installer-a",
token="one-time-enrollment-secret",
)
provider = ConfiguredEnrollmentTokenProvider([credential])
principal = provider.authenticate(
_Request(headers={"authorization": "Bearer one-time-enrollment-secret"})
)
assert principal is not None
assert principal.id == "installer-a"
assert principal.token_digest == digest_token("one-time-enrollment-secret")
assert "one-time-enrollment-secret" not in repr(credential)
assert "one-time-enrollment-secret" not in repr(provider.__dict__)
def test_repository_host_auth_and_chain_preserve_host_scope() -> None:
class Repository:
def authenticate_enrolled_host(self, credential_digest: str) -> str | None:
if credential_digest == digest_token("dynamic-host-secret"):
return "host-managed"
return None
configured = ConfiguredBearerAuthProvider(
[BearerCredential(principal_id="sdk", token="sdk-secret")]
)
provider = ChainedAuthProvider(
[configured, RepositoryHostAuthProvider(Repository())] # type: ignore[arg-type]
)
public_principal = provider.authenticate(
_Request(headers={"authorization": "Bearer sdk-secret"})
)
host_principal = provider.authenticate(
_Request(headers={"authorization": "Bearer dynamic-host-secret"})
)
assert public_principal is not None
assert public_principal.id == "sdk"
assert host_principal is not None
assert host_principal.host_id == "host-managed"
assert host_principal.scopes == frozenset()
+70
View File
@@ -150,6 +150,8 @@ def test_client_applies_bearer_token_to_every_public_method(tmp_path) -> None:
task_id = client.submit_task(goal="authenticated")["task_id"]
assert client.get_task_status(task_id)["status"] == "queued"
assert client.list_tasks()["total"] == 1
assert client.get_task_attempts(task_id) == []
assert client.list_devices() == []
assert client.list_hosts() == []
assert client.list_plugins() == []
@@ -164,6 +166,74 @@ def test_client_applies_bearer_token_to_every_public_method(tmp_path) -> None:
)
def test_client_list_tasks_and_get_attempts_match_direct_http(tmp_path) -> None:
from datetime import UTC, datetime
from core.models import Device
client, pool = _client_and_pool(tmp_path)
pool.sync_host_devices(
"host-a",
[Device(id="dev-a", driver_type="wda", status="idle")], # type: ignore[arg-type]
)
first_id = client.submit_task(goal="first")["task_id"]
second_id = client.submit_task(goal="second")["task_id"]
# Drive one task through an attempt so get_task_attempts has data.
scheduler = TaskScheduler(pool, CloudStore(tmp_path / "cloud.sqlite3"), _config())
scheduler.assign()
task = scheduler.store.get_task(first_id)
if task is not None and task.status == "assigned":
scheduler.store.record_task_result(
task_id=first_id,
attempt=task.attempt_count,
lease_id=task.lease_id or "",
host_id=task.assigned_host_id or "",
status="failed",
failure_reason="boom",
terminal_result={"exit_code": 1},
completed_at=datetime.now(UTC),
)
# The TestClient is the HTTP boundary — issuing direct calls through it
# exercises the same FastAPI routes the client does, which is the parity
# contract platform-sdk already relies on.
http_client = client._http # type: ignore[attr-defined]
direct_list = http_client.get(
client._url("/tasks"), # type: ignore[attr-defined]
params={"limit": 50, "offset": 0},
headers=client._headers, # type: ignore[attr-defined]
).json()
direct_attempts = http_client.get(
f"{client._url('/tasks')}/{first_id}/attempts", # type: ignore[attr-defined]
headers=client._headers, # type: ignore[attr-defined]
).json()
via_client = client.list_tasks()
assert via_client == direct_list
assert via_client["total"] == 2
# Most-recent-first: second (the newer) before first.
assert [item["id"] for item in via_client["items"]] == [second_id, first_id]
failed_only = client.list_tasks(status="failed")
assert failed_only["total"] == 1
assert failed_only["items"][0]["id"] == first_id
attempts = client.get_task_attempts(first_id)
assert attempts == direct_attempts
assert len(attempts) == 1
assert attempts[0]["status"] == "failed"
assert attempts[0]["failure_reason"] == "boom"
def test_client_get_attempts_raises_for_unknown_task(tmp_path) -> None:
import httpx
client, _ = _client_and_pool(tmp_path)
with pytest.raises(httpx.HTTPStatusError):
client.get_task_attempts("does-not-exist")
def test_client_raises_typed_authorization_error_without_exposing_token(
tmp_path,
) -> None:
+19 -1
View File
@@ -47,6 +47,20 @@ def test_load_control_config_parses_deployment_values() -> None:
assert "sdk-secret" not in repr(config)
def test_load_control_config_parses_enrollment_credentials() -> None:
config = load_control_config(
{
"CLOUD_ENROLLMENT_TOKENS_JSON": (
'[{"principal_id":"installer-a","token":"one-time-enrollment-secret"}]'
)
}
)
assert len(config.enrollment_credentials) == 1
assert config.enrollment_credentials[0].principal_id == "installer-a"
assert "one-time-enrollment-secret" not in repr(config)
@pytest.mark.parametrize(
"environment,database_url",
[("invalid", "sqlite:///test.db"), ("local", "mysql://db/cloud")],
@@ -97,13 +111,17 @@ def test_load_control_config_rejects_missing_production_credentials() -> None:
"CLOUD_HOST_CREDENTIALS_JSON",
'[{"principal_id":"agent","token":"secret","scopes":[]}]',
),
(
"CLOUD_ENROLLMENT_TOKENS_JSON",
'[{"principal_id":"installer","token":123}]',
),
],
)
def test_load_control_config_rejects_invalid_credentials(
name: str,
value: str,
) -> None:
with pytest.raises(CloudConfigurationError, match="credentials") as error:
with pytest.raises(CloudConfigurationError, match="credential") as error:
load_control_config({name: value})
assert "secret" not in str(error.value)
+59
View File
@@ -29,12 +29,23 @@ def test_forward_and_downgrade_migrations_on_empty_database(tmp_path) -> None:
table_names = set(inspect(engine).get_table_names())
assert {
"host_registrations",
"device_enrollments",
"pooled_devices",
"scheduled_tasks",
"plugins",
"task_attempts",
} <= table_names
assert current_revision(database_url) == HEAD_REVISION
host_columns = {
column["name"]
for column in inspect(engine).get_columns("host_registrations")
}
assert {
"agent_instance_id",
"credential_digest",
"enrollment_token_digest",
"revoked_at",
} <= host_columns
finally:
engine.dispose()
@@ -43,6 +54,7 @@ def test_forward_and_downgrade_migrations_on_empty_database(tmp_path) -> None:
engine = create_engine(database_url)
try:
inspector = inspect(engine)
assert "device_enrollments" not in inspector.get_table_names()
assert "task_attempts" not in inspector.get_table_names()
task_columns = {
column["name"] for column in inspector.get_columns("scheduled_tasks")
@@ -95,6 +107,14 @@ def test_legacy_data_survives_upgrade_and_downgrade(tmp_path) -> None:
column["name"] for column in inspect(engine).get_columns("scheduled_tasks")
}
assert {"attempt_count", "lease_id", "result_json"} <= task_columns
host_columns = {
column["name"]
for column in inspect(engine).get_columns("host_registrations")
}
assert {"agent_instance_id", "credential_digest", "revoked_at"} <= (
host_columns
)
assert "device_enrollments" in inspect(engine).get_table_names()
finally:
engine.dispose()
@@ -114,6 +134,40 @@ def test_legacy_data_survives_upgrade_and_downgrade(tmp_path) -> None:
engine.dispose()
def test_enrollment_downgrade_to_revision_0001_preserves_legacy_state(
tmp_path,
) -> None:
database_url = _database_url(tmp_path)
upgrade_database(database_url)
engine = create_engine(database_url)
try:
with engine.begin() as connection:
connection.execute(
text(
"insert into host_registrations "
"(host_id, address, last_seen_at) values "
"('legacy-host', null, '2026-01-01T00:00:00+00:00')"
)
)
finally:
engine.dispose()
downgrade_database(database_url, "0001_cloud_repository")
engine = create_engine(database_url)
try:
inspector = inspect(engine)
assert "device_enrollments" not in inspector.get_table_names()
assert connection_scalar(engine, "select count(*) from host_registrations") == 1
host_columns = {
column["name"] for column in inspector.get_columns("host_registrations")
}
assert "credential_digest" not in host_columns
assert current_revision(database_url) == "0001_cloud_repository"
finally:
engine.dispose()
def test_schema_readiness_requires_head_revision(tmp_path) -> None:
database_url = _database_url(tmp_path)
@@ -146,3 +200,8 @@ def _create_legacy_schema(connection) -> None:
"name text primary key, version text not null, entry_point_kind text not null, "
"target text not null, wired integer not null)"
)
def connection_scalar(engine, statement: str):
with engine.connect() as connection:
return connection.scalar(text(statement))
+244 -1
View File
@@ -14,7 +14,14 @@ from cloud.database import CloudDatabase
from cloud.db_models import TaskAttemptRow
from cloud.plugins import PluginManifest
from cloud.pool import PooledDevice
from cloud.repository import CloudRepository, LeasedAssignment, TaskAttemptRecord
from cloud.repository import (
CloudRepository,
DeviceEnrollmentConflictError,
EnrollmentTokenConflictError,
HostEnrollmentConflictError,
LeasedAssignment,
TaskAttemptRecord,
)
from cloud.scheduler import ScheduledTask, TaskConstraints
@@ -53,12 +60,21 @@ def test_cloud_repository_exposes_crud_and_atomic_lease_operations() -> None:
members = get_protocol_members(CloudRepository)
assert {
"enroll_host",
"authenticate_enrolled_host",
"revoke_enrolled_host",
"is_enrollment_managed_host",
"enroll_device",
"get_device_enrollment",
"list_device_enrollments",
"upsert_host",
"replace_host_devices",
"list_hosts",
"list_devices",
"enqueue_task",
"get_task",
"list_tasks",
"count_tasks",
"save_plugin",
"assign_task",
"claim_assignment",
@@ -76,6 +92,134 @@ def test_repository_transfer_records_are_immutable() -> None:
assert LeasedAssignment.__dataclass_params__.frozen is True
def test_host_enrollment_is_idempotent_and_token_is_one_time(
database_url: str,
) -> None:
database = CloudDatabase(database_url)
repository = database.repository
enrolled_at = datetime(2026, 7, 13, 4, 0, tzinfo=UTC)
host_id = _unique_id("managed-host")
agent_instance_id = _unique_id("agent-instance")
credential_digest = _unique_id("credential-digest")
enrollment_digest = _unique_id("enrollment-digest")
try:
created = repository.enroll_host(
host_id=host_id,
agent_instance_id=agent_instance_id,
credential_digest=credential_digest,
enrollment_token_digest=enrollment_digest,
display_name="Edge Mac",
enrolled_at=enrolled_at,
)
assert created.host_id == host_id
assert created.agent_instance_id == agent_instance_id
assert created.display_name == "Edge Mac"
assert created.enrolled_at == enrolled_at
assert repository.is_enrollment_managed_host(host_id) is True
assert repository.authenticate_enrolled_host(credential_digest) == host_id
retried = repository.enroll_host(
host_id=_unique_id("ignored-host"),
agent_instance_id=agent_instance_id,
credential_digest=credential_digest,
enrollment_token_digest=enrollment_digest,
display_name="Renamed Edge Mac",
enrolled_at=enrolled_at + timedelta(minutes=1),
)
assert retried == created
with pytest.raises(HostEnrollmentConflictError):
repository.enroll_host(
host_id=_unique_id("host"),
agent_instance_id=agent_instance_id,
credential_digest=_unique_id("different-credential"),
enrollment_token_digest=enrollment_digest,
display_name=None,
enrolled_at=enrolled_at,
)
with pytest.raises(EnrollmentTokenConflictError):
repository.enroll_host(
host_id=_unique_id("host"),
agent_instance_id=_unique_id("different-instance"),
credential_digest=_unique_id("credential"),
enrollment_token_digest=enrollment_digest,
display_name=None,
enrolled_at=enrolled_at,
)
assert repository.revoke_enrolled_host(
host_id,
revoked_at=enrolled_at + timedelta(hours=1),
)
assert repository.authenticate_enrolled_host(credential_digest) is None
assert repository.get_host(host_id) is not None
finally:
database.close()
def test_device_enrollment_is_host_scoped_and_idempotent(
database_url: str,
) -> None:
database = CloudDatabase(database_url)
repository = database.repository
enrolled_at = datetime(2026, 7, 13, 5, 0, tzinfo=UTC)
host_a = _unique_id("host-a")
host_b = _unique_id("host-b")
local_device_id = _unique_id("local-device")
try:
device_a = repository.enroll_device(
device_id=_unique_id("cloud-device"),
host_id=host_a,
local_device_id=local_device_id,
driver_type="wda",
name="iPhone",
capability_tags=["ios"],
enrolled_at=enrolled_at,
)
retried = repository.enroll_device(
device_id=_unique_id("ignored-device"),
host_id=host_a,
local_device_id=local_device_id,
driver_type="wda",
name="Renamed iPhone",
capability_tags=["ios", "physical"],
enrolled_at=enrolled_at + timedelta(minutes=1),
)
assert retried.device_id == device_a.device_id
assert retried.name == "Renamed iPhone"
assert retried.capability_tags == ["ios", "physical"]
assert repository.get_device_enrollment(device_a.device_id) == retried
assert repository.list_device_enrollments(host_a) == [retried]
device_b = repository.enroll_device(
device_id=_unique_id("cloud-device"),
host_id=host_b,
local_device_id=local_device_id,
driver_type="wda",
name="Moved iPhone",
capability_tags=[],
enrolled_at=enrolled_at,
)
assert device_b.device_id != device_a.device_id
assert device_b.host_id == host_b
with pytest.raises(DeviceEnrollmentConflictError):
repository.enroll_device(
device_id=_unique_id("device"),
host_id=host_a,
local_device_id=local_device_id,
driver_type="android",
name=None,
capability_tags=[],
enrolled_at=enrolled_at,
)
finally:
database.close()
def test_repository_crud_contract(database_url: str) -> None:
database = CloudDatabase(database_url)
repository = database.repository
@@ -311,6 +455,105 @@ def test_task_attempt_history_is_ordered_and_complete(database_url: str) -> None
database.close()
def test_list_tasks_returns_empty_when_repository_has_no_tasks(
database_url: str,
) -> None:
database = CloudDatabase(database_url)
try:
assert database.repository.list_tasks() == []
assert database.repository.count_tasks() == 0
assert database.repository.list_tasks(status="queued") == []
assert database.repository.count_tasks(status="queued") == 0
finally:
database.close()
def test_list_tasks_returns_most_recent_first_with_optional_status_filter(
database_url: str,
) -> None:
database = CloudDatabase(database_url)
base = datetime(2026, 7, 12, 6, 0, tzinfo=UTC)
queued_ids = [_unique_id("list-task") for _ in range(2)]
failed_ids = [_unique_id("list-task") for _ in range(2)]
try:
for index, task_id in enumerate(queued_ids):
database.repository.enqueue_task(
ScheduledTask(
id=task_id,
goal="queued goal",
workflow_definition_id=None,
constraints=TaskConstraints(),
status="queued",
created_at=base + timedelta(seconds=index),
)
)
for index, task_id in enumerate(failed_ids):
database.repository.enqueue_task(
ScheduledTask(
id=task_id,
goal="failed goal",
workflow_definition_id=None,
constraints=TaskConstraints(),
status="failed",
failure_reason="boom",
created_at=base + timedelta(seconds=10 + index),
)
)
unfiltered = database.repository.list_tasks()
assert [task.id for task in unfiltered] == (
list(reversed(failed_ids)) + list(reversed(queued_ids))
)
assert database.repository.count_tasks() == 4
queued = database.repository.list_tasks(status="queued")
assert [task.id for task in queued] == list(reversed(queued_ids))
assert all(task.status == "queued" for task in queued)
assert database.repository.count_tasks(status="queued") == 2
failed = database.repository.list_tasks(status="failed")
assert [task.id for task in failed] == list(reversed(failed_ids))
assert database.repository.count_tasks(status="failed") == 2
# A status with no matches returns an empty page and zero count.
assert database.repository.list_tasks(status="done") == []
assert database.repository.count_tasks(status="done") == 0
finally:
database.close()
def test_list_tasks_pagination_bounds(database_url: str) -> None:
database = CloudDatabase(database_url)
base = datetime(2026, 7, 12, 7, 0, tzinfo=UTC)
task_ids = [_unique_id("page-task") for _ in range(4)]
try:
for index, task_id in enumerate(task_ids):
database.repository.enqueue_task(
ScheduledTask(
id=task_id,
goal=f"goal-{index}",
workflow_definition_id=None,
constraints=TaskConstraints(),
created_at=base + timedelta(seconds=index),
)
)
# Most-recent-first ordering means page 1 returns the newest two ids.
page_one = database.repository.list_tasks(limit=2, offset=0)
assert [task.id for task in page_one] == [task_ids[3], task_ids[2]]
page_two = database.repository.list_tasks(limit=2, offset=2)
assert [task.id for task in page_two] == [task_ids[1], task_ids[0]]
# An offset past the end of the result set returns an empty page,
# not an error — the caller is expected to consult count_tasks().
assert database.repository.list_tasks(limit=10, offset=100) == []
finally:
database.close()
def test_atomic_assignment_creates_lease_attempt_and_reservation(
database_url: str,
) -> None:
+98
View File
@@ -272,6 +272,8 @@ def test_submit_with_constraints(tmp_path) -> None:
[
("post", "/v1/tasks", {"goal": "x"}, "tasks:submit"),
("get", "/v1/tasks/missing", None, "tasks:read"),
("get", "/v1/tasks", None, "tasks:read"),
("get", "/v1/tasks/missing/attempts", None, "tasks:read"),
("get", "/v1/devices", None, "pool:read"),
("get", "/v1/hosts", None, "pool:read"),
("get", "/v1/plugins", None, "plugins:read"),
@@ -332,6 +334,102 @@ def test_every_public_route_enforces_its_scope(
assert authorized.status_code not in {401, 403}
def test_list_tasks_returns_summary_with_pagination_and_status_filter(
tmp_path,
) -> None:
app, pool, scheduler, _ = _build_app(tmp_path)
# Submit three tasks; assign one so the population covers multiple statuses.
first_id = scheduler.submit(goal="first")
second_id = scheduler.submit(goal="second")
pool.sync_host_devices(
"host-a",
[Device(id="device-a", driver_type="wda", status="idle")], # type: ignore[arg-type]
)
scheduler.assign()
# assigned_id is whichever task the scheduler picked (oldest-first = first_id).
assigned_id = first_id
client = _client_for(app)
unfiltered = client.get("/v1/tasks").json()
assert unfiltered["total"] == 2
assert unfiltered["limit"] == 50
assert unfiltered["offset"] == 0
assert [item["id"] for item in unfiltered["items"]] == [second_id, assigned_id]
# Lease id must not leak through the summary surface.
assert all("lease_id" not in item for item in unfiltered["items"])
queued_only = client.get("/v1/tasks", params={"status": "queued"}).json()
assert queued_only["total"] == 1
assert [item["id"] for item in queued_only["items"]] == [second_id]
assert all(item["status"] == "queued" for item in queued_only["items"])
assigned_only = client.get(
"/v1/tasks", params={"status": "assigned"}
).json()
assert assigned_only["total"] == 1
assert [item["id"] for item in assigned_only["items"]] == [assigned_id]
def test_list_tasks_rejects_page_size_above_maximum(tmp_path) -> None:
app, _, _, _ = _build_app(tmp_path)
client = _client_for(app)
too_large = client.get("/v1/tasks", params={"limit": 101})
assert too_large.status_code == 422
# And the boundary value is accepted.
boundary = client.get("/v1/tasks", params={"limit": 100})
assert boundary.status_code == 200
def test_list_tasks_rejects_negative_offset(tmp_path) -> None:
app, _, _, _ = _build_app(tmp_path)
client = _client_for(app)
response = client.get("/v1/tasks", params={"offset": -1})
assert response.status_code == 422
def test_list_task_attempts_returns_chronological_history(tmp_path) -> None:
app, pool, scheduler, _ = _build_app(tmp_path)
pool.sync_host_devices(
"host-a",
[Device(id="device-a", driver_type="wda", status="idle")], # type: ignore[arg-type]
)
task_id = scheduler.submit(goal="attempt me")
scheduler.assign()
task = scheduler.store.get_task(task_id)
scheduler.store.record_task_result(
task_id=task_id,
attempt=task.attempt_count,
lease_id=task.lease_id or "",
host_id=task.assigned_host_id or "",
status="failed",
failure_reason="boom",
terminal_result={"exit_code": 1},
completed_at=datetime.now(UTC),
)
client = _client_for(app)
resp = client.get(f"/v1/tasks/{task_id}/attempts")
assert resp.status_code == 200, resp.text
body = resp.json()
assert len(body) == 1
assert body[0]["task_id"] == task_id
assert body[0]["status"] == "failed"
assert body[0]["failure_reason"] == "boom"
assert body[0]["terminal_result"] == {"exit_code": 1}
assert body[0]["host_id"] == "host-a"
assert body[0]["device_id"] == "device-a"
def test_list_task_attempts_returns_404_for_unknown_task(tmp_path) -> None:
app, _, _, _ = _build_app(tmp_path)
client = _client_for(app)
resp = client.get("/v1/tasks/does-not-exist/attempts")
assert resp.status_code == 404, resp.text
assert "does-not-exist" in resp.json()["detail"]
def test_plugin_admin_scope_is_checked_before_registration(
tmp_path,
monkeypatch,
+12
View File
@@ -32,6 +32,15 @@ def test_compose_defines_database_control_plane_and_outbound_host_agent() -> Non
assert services["host-agent"]["environment"]["AI_PLANNER_ENABLED"] == (
"${AI_PLANNER_ENABLED:-false}"
)
assert (
services["cloud-api"]["environment"]["CLOUD_ENROLLMENT_TOKENS_JSON"]
== "${CLOUD_ENROLLMENT_TOKENS_JSON:-[]}"
)
assert (
services["host-agent"]["environment"]["HOST_AGENT_IDENTITY_PATH"]
== "${HOST_AGENT_IDENTITY_PATH:-/app/tasks/host_identity.json}"
)
assert "ports" not in services["host-agent"]
def test_container_uses_locked_workspace_install_and_migrations() -> None:
@@ -54,8 +63,11 @@ def test_example_environment_contains_only_placeholder_credentials() -> None:
public_credentials = json.loads(values["CLOUD_PUBLIC_CREDENTIALS_JSON"])
host_credentials = json.loads(values["CLOUD_HOST_CREDENTIALS_JSON"])
enrollment_credentials = json.loads(values["CLOUD_ENROLLMENT_TOKENS_JSON"])
assert public_credentials[0]["token"].startswith("change-me-")
assert host_credentials[0]["token"] == values["HOST_AGENT_TOKEN"]
assert host_credentials[0]["token"].startswith("change-me-")
assert host_credentials[0]["host_id"] == values["HOST_AGENT_HOST_ID"]
assert enrollment_credentials[0]["token"].startswith("change-me-")
assert values["HOST_AGENT_IDENTITY_PATH"] == "/app/tasks/host_identity.json"
+27
View File
@@ -27,6 +27,7 @@ def test_device_config_store_add_remove_list_and_get(tmp_path) -> None:
"server_url": "http://127.0.0.1:4723",
"udid": "abc123",
},
"cloud_device_id": None,
}
assert [config["device_id"] for config in store.list()] == ["iphone-1", "iphone-2"]
@@ -56,3 +57,29 @@ def test_device_config_store_unknown_device_remove_is_noop(tmp_path) -> None:
store.remove("missing")
assert store.get("missing") is None
def test_device_config_store_persists_cloud_mapping_without_losing_it_on_update(
tmp_path,
) -> None:
store = DeviceConfigStore(tmp_path / "device_config.sqlite3")
store.add(
device_id="iphone-local",
name="iPhone",
driver_type="wda",
connection_info={"udid": "abc"},
)
assert store.set_cloud_device_id("iphone-local", "device-cloud-a") is True
store.add(
device_id="iphone-local",
name="Renamed iPhone",
driver_type="wda",
connection_info={"udid": "abc", "wda_local_port": 8101},
)
config = store.get("iphone-local")
assert config is not None
assert config["cloud_device_id"] == "device-cloud-a"
assert config["name"] == "Renamed iPhone"
assert store.set_cloud_device_id("missing", "device-cloud-b") is False