Implement edge-host-self-enrollment
Tests / Test passed: 581

Host Agent:
- One-time local operator account bootstrap (PBKDF2-HMAC-SHA256, atomic
  0600-permission write) gating the daemon's first unattended start via a
  new `setup` CLI subcommand.
- Default control-plane URL now https://amcp.home.jerryyan.top (env var
  override unchanged).
- Enrollment no longer requires a pre-issued token; falls back to
  zero-token self-service enrollment when none is configured.

Cloud control plane:
- CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED (default false) opt-in flag.
- SelfServiceEnrollmentAuthProvider + ChainedEnrollmentAuthProvider:
  configured tokens still take priority; self-service only applies when
  no token matches, preserving edge-host-enrollment's token-bound path.
- Fixed a latent bug in sql_repository.py::enroll_host: the token-conflict
  lookup used `== enrollment_token_digest`, which SQLAlchemy compiles to
  `IS NULL` when the value is None, so every self-service enrollment after
  the first would have falsely collided with an existing NULL-digest host.
  Skipped that lookup entirely when the digest is None.

Docs/deploy: .env.example, compose.yaml, compose.deploy.yaml,
CLOUD_DEPLOYMENT.md, MACOS_IPHONE_SETUP.md updated for the new flag,
URL default, and required `device-host-agent setup` step.

Verification: 494 non-integration tests pass; openspec validate --strict
passes. PostgreSQL-backed contract tests and full manual end-to-end
verification were not run (no Postgres/Docker or reachable cloud-api in
this environment); noted as unchecked in tasks.md 7.2/7.4.
This commit is contained in:
2026-07-13 18:30:49 +08:00
parent a2802c6320
commit efeb3eb926
24 changed files with 838 additions and 68 deletions
+8
View File
@@ -11,6 +11,10 @@ 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_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_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_ENROLLMENT_TOKENS_JSON=[{"principal_id":"edge-installer","token":"change-me-enrollment-token"}]
# Zero-token self-service Host enrollment (see docs/CLOUD_DEPLOYMENT.md). Only
# enable this on a deployment whose control-plane URL is not reachable by
# untrusted networks; any caller that reaches it can register itself as a Host.
CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED=false
CLOUD_SCHEDULER_INTERVAL_SECONDS=1 CLOUD_SCHEDULER_INTERVAL_SECONDS=1
CLOUD_LEASE_REAPER_INTERVAL_SECONDS=5 CLOUD_LEASE_REAPER_INTERVAL_SECONDS=5
CLOUD_LEASE_DURATION_SECONDS=60 CLOUD_LEASE_DURATION_SECONDS=60
@@ -29,6 +33,10 @@ HOST_AGENT_HOST_ID=host-local
HOST_AGENT_TOKEN=change-me-host-token HOST_AGENT_TOKEN=change-me-host-token
HOST_AGENT_ENROLLMENT_TOKEN= HOST_AGENT_ENROLLMENT_TOKEN=
HOST_AGENT_IDENTITY_PATH=/app/tasks/host_identity.json HOST_AGENT_IDENTITY_PATH=/app/tasks/host_identity.json
# One-time local operator account gate; run `device-host-agent setup`
# interactively before the daemon's first unattended start (see
# docs/CLOUD_DEPLOYMENT.md).
HOST_AGENT_LOCAL_ACCOUNT_PATH=/app/tasks/host_local_account.json
HOST_AGENT_DISPLAY_NAME= HOST_AGENT_DISPLAY_NAME=
HOST_AGENT_TASKS_PATH=./tasks HOST_AGENT_TASKS_PATH=./tasks
HOST_AGENT_HEARTBEAT_INTERVAL_SECONDS=30 HOST_AGENT_HEARTBEAT_INTERVAL_SECONDS=30
+11 -2
View File
@@ -18,8 +18,10 @@ from starlette.types import Scope
from cloud.auth import ( from cloud.auth import (
ChainedAuthProvider, ChainedAuthProvider,
ChainedEnrollmentAuthProvider,
ConfiguredEnrollmentTokenProvider, ConfiguredEnrollmentTokenProvider,
RepositoryHostAuthProvider, RepositoryHostAuthProvider,
SelfServiceEnrollmentAuthProvider,
UserSessionAuthProvider, UserSessionAuthProvider,
create_auth_provider, create_auth_provider,
) )
@@ -134,8 +136,15 @@ def create_app(
RepositoryHostAuthProvider(repository), # type: ignore[arg-type] RepositoryHostAuthProvider(repository), # type: ignore[arg-type]
) )
) )
enrollment_auth_provider = ConfiguredEnrollmentTokenProvider( enrollment_auth_provider = ChainedEnrollmentAuthProvider(
control_config.enrollment_credentials (
ConfiguredEnrollmentTokenProvider(control_config.enrollment_credentials),
*(
(SelfServiceEnrollmentAuthProvider(),)
if control_config.self_service_enrollment_enabled
else ()
),
)
) )
domain_config = CloudConfig( domain_config = CloudConfig(
lease_duration_seconds=control_config.lease_duration_seconds, lease_duration_seconds=control_config.lease_duration_seconds,
+66 -2
View File
@@ -1,12 +1,76 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import getpass
import sys
from collections.abc import Sequence from collections.abc import Sequence
from dataclasses import replace
from host_agent.app import create_application from host_agent.app import create_application
from host_agent.config import load_host_agent_config
from host_agent.local_account import LocalAccountStore
class LocalAccountSetupError(RuntimeError):
"""Raised when local account bootstrap cannot proceed."""
def main(argv: Sequence[str] | None = None) -> None: def main(argv: Sequence[str] | None = None) -> None:
parser = argparse.ArgumentParser(description="Run the Device Host Agent") parser = argparse.ArgumentParser(description="Run the Device Host Agent")
parser.parse_args(argv) subparsers = parser.add_subparsers(dest="command")
create_application().run() subparsers.add_parser("setup", help="Create the local operator account")
args = parser.parse_args(argv)
try:
if args.command == "setup":
_run_setup()
return
config = _resolve_config_with_local_account()
except LocalAccountSetupError as exc:
print(f"error: {exc}", file=sys.stderr)
raise SystemExit(1) from exc
create_application(config=config).run()
def _run_setup() -> None:
config = load_host_agent_config()
store = LocalAccountStore(config.local_account_path)
if store.load() is not None:
confirm = input(
"A local account already exists. Overwrite it? [y/N] "
).strip().lower()
if confirm != "y":
print("Setup cancelled; existing account left unchanged.")
return
account = _prompt_and_create(store)
print(f"Local account '{account.username}' created.")
def _resolve_config_with_local_account():
config = load_host_agent_config()
store = LocalAccountStore(config.local_account_path)
account = store.load()
if account is None:
if not sys.stdin.isatty():
raise LocalAccountSetupError(
"no local account configured; run `device-host-agent setup` "
"on an interactive terminal to create one"
)
account = _prompt_and_create(store)
if not config.display_name:
config = replace(config, display_name=account.username)
return config
def _prompt_and_create(store: LocalAccountStore):
username = input("Username: ").strip()
if not username:
raise LocalAccountSetupError("username must not be empty")
password = getpass.getpass("Password: ")
confirm = getpass.getpass("Confirm password: ")
if not password:
raise LocalAccountSetupError("password must not be empty")
if password != confirm:
raise LocalAccountSetupError("passwords do not match")
return store.create(username, password)
+4 -5
View File
@@ -53,12 +53,10 @@ class HostAgentEnrollmentClient:
host_token: str, host_token: str,
display_name: str | None, display_name: str | None,
) -> HostEnrollmentResponse: ) -> HostEnrollmentResponse:
if not self.config.enrollment_token:
raise HostAgentAPIError(0, "Host enrollment token is unavailable")
response = self._request( response = self._request(
"POST", "POST",
"/internal/v1/enrollments", "/internal/v1/enrollments",
token=self.config.enrollment_token, token=self.config.enrollment_token or None,
json={ json={
"agent_instance_id": agent_instance_id, "agent_instance_id": agent_instance_id,
"host_token": host_token, "host_token": host_token,
@@ -99,9 +97,10 @@ class HostAgentEnrollmentClient:
method: str, method: str,
path: str, path: str,
*, *,
token: str, token: str | None,
json: dict[str, Any], json: dict[str, Any],
) -> httpx.Response: ) -> httpx.Response:
headers = {"Authorization": f"Bearer {token}"} if token else {}
backoff = self.config.retry_backoff_seconds backoff = self.config.retry_backoff_seconds
for attempt in range(1, self.config.max_retry_attempts + 1): for attempt in range(1, self.config.max_retry_attempts + 1):
try: try:
@@ -109,7 +108,7 @@ class HostAgentEnrollmentClient:
method, method,
path, path,
json=json, json=json,
headers={"Authorization": f"Bearer {token}"}, headers=headers,
) )
except httpx.TransportError: except httpx.TransportError:
if attempt == self.config.max_retry_attempts: if attempt == self.config.max_retry_attempts:
+8 -6
View File
@@ -18,6 +18,7 @@ class HostAgentConfig:
token: str = field(default="", repr=False) token: str = field(default="", repr=False)
enrollment_token: str = field(default="", repr=False) enrollment_token: str = field(default="", repr=False)
identity_path: Path = Path("tasks/host_identity.json") identity_path: Path = Path("tasks/host_identity.json")
local_account_path: Path = Path("tasks/host_local_account.json")
enrollment_managed: bool = False enrollment_managed: bool = False
display_name: str | None = None display_name: str | None = None
heartbeat_interval_seconds: float = 30.0 heartbeat_interval_seconds: float = 30.0
@@ -34,7 +35,7 @@ def load_host_agent_config(
control_plane_url = ( control_plane_url = (
values.get( values.get(
"HOST_AGENT_CONTROL_PLANE_URL", "HOST_AGENT_CONTROL_PLANE_URL",
"http://127.0.0.1:8001", "https://amcp.home.jerryyan.top",
) )
.strip() .strip()
.rstrip("/") .rstrip("/")
@@ -55,11 +56,11 @@ def load_host_agent_config(
identity_path = Path( identity_path = Path(
values.get("HOST_AGENT_IDENTITY_PATH", "tasks/host_identity.json").strip() 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(): local_account_path = Path(
raise HostAgentConfigurationError( values.get(
"explicit Host credentials, an enrollment token, or existing identity state " "HOST_AGENT_LOCAL_ACCOUNT_PATH", "tasks/host_local_account.json"
"is required" ).strip()
) )
config = HostAgentConfig( config = HostAgentConfig(
control_plane_url=control_plane_url, control_plane_url=control_plane_url,
@@ -67,6 +68,7 @@ def load_host_agent_config(
token=token, token=token,
enrollment_token=enrollment_token, enrollment_token=enrollment_token,
identity_path=identity_path, identity_path=identity_path,
local_account_path=local_account_path,
enrollment_managed=not bool(host_id), enrollment_managed=not bool(host_id),
display_name=values.get("HOST_AGENT_DISPLAY_NAME") or None, display_name=values.get("HOST_AGENT_DISPLAY_NAME") or None,
heartbeat_interval_seconds=_positive_float( heartbeat_interval_seconds=_positive_float(
@@ -3,7 +3,7 @@ from __future__ import annotations
from dataclasses import replace from dataclasses import replace
from host_agent.client import HostAgentEnrollmentClient from host_agent.client import HostAgentEnrollmentClient
from host_agent.config import HostAgentConfig, HostAgentConfigurationError from host_agent.config import HostAgentConfig
from host_agent.identity import HostIdentityStore from host_agent.identity import HostIdentityStore
@@ -17,10 +17,6 @@ def resolve_host_identity(
return config return config
state = identity_store.load_or_create() state = identity_store.load_or_create()
if state.host_id is None: 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( response = client.enroll_host(
agent_instance_id=state.agent_instance_id, agent_instance_id=state.agent_instance_id,
host_token=state.token, host_token=state.token,
@@ -0,0 +1,116 @@
from __future__ import annotations
import hmac
import json
import os
from dataclasses import dataclass, field
from hashlib import pbkdf2_hmac
from pathlib import Path
from secrets import token_bytes
from uuid import uuid4
PBKDF2_ITERATIONS = 600_000
SALT_BYTES = 16
class LocalAccountStateError(RuntimeError):
"""Raised when persisted local account state is missing or invalid."""
@dataclass(frozen=True)
class LocalAccountState:
username: str
salt: bytes = field(repr=False)
iterations: int
password_hash: bytes = field(repr=False)
class LocalAccountStore:
def __init__(self, path: str | Path) -> None:
self.path = Path(path)
def load(self) -> LocalAccountState | 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 LocalAccountStateError("local account state is unreadable") from exc
if not isinstance(payload, dict):
raise LocalAccountStateError("local account state must be an object")
username = payload.get("username")
salt_hex = payload.get("salt")
iterations = payload.get("iterations")
password_hash_hex = payload.get("password_hash")
if (
not isinstance(username, str)
or not username
or not isinstance(salt_hex, str)
or not isinstance(iterations, int)
or iterations <= 0
or not isinstance(password_hash_hex, str)
):
raise LocalAccountStateError("local account state is invalid")
try:
salt = bytes.fromhex(salt_hex)
password_hash = bytes.fromhex(password_hash_hex)
except ValueError as exc:
raise LocalAccountStateError("local account state is invalid") from exc
return LocalAccountState(
username=username,
salt=salt,
iterations=iterations,
password_hash=password_hash,
)
def create(self, username: str, password: str) -> LocalAccountState:
if not username.strip():
raise ValueError("username must not be empty")
if not password:
raise ValueError("password must not be empty")
salt = token_bytes(SALT_BYTES)
state = LocalAccountState(
username=username,
salt=salt,
iterations=PBKDF2_ITERATIONS,
password_hash=_derive_hash(password, salt, PBKDF2_ITERATIONS),
)
self._write(state)
return state
def verify(self, state: LocalAccountState, password: str) -> bool:
candidate = _derive_hash(password, state.salt, state.iterations)
return hmac.compare_digest(candidate, state.password_hash)
def _write(self, state: LocalAccountState) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
temporary = self.path.with_name(f".{self.path.name}.{uuid4().hex}.tmp")
payload = {
"username": state.username,
"salt": state.salt.hex(),
"iterations": state.iterations,
"password_hash": state.password_hash.hex(),
}
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 _derive_hash(password: str, salt: bytes, iterations: int) -> bytes:
return pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
def _restrict_permissions(path: Path) -> None:
try:
path.chmod(0o600)
except OSError:
return
+122
View File
@@ -0,0 +1,122 @@
from __future__ import annotations
import sys
import pytest
from host_agent import cli
from host_agent.local_account import LocalAccountStore
class _RecordingApplication:
def __init__(self) -> None:
self.ran = False
def run(self) -> None:
self.ran = True
def _base_env(tmp_path) -> dict[str, str]:
return {
"HOST_AGENT_CONTROL_PLANE_URL": "https://cloud.example",
"HOST_AGENT_LOCAL_ACCOUNT_PATH": str(tmp_path / "account.json"),
"HOST_AGENT_IDENTITY_PATH": str(tmp_path / "identity.json"),
}
def _set_env(monkeypatch, tmp_path) -> None:
for key, value in _base_env(tmp_path).items():
monkeypatch.setenv(key, value)
def _patch_create_application(monkeypatch) -> dict:
captured: dict = {}
def fake_create_application(*, config=None, **kwargs):
captured["config"] = config
app = _RecordingApplication()
captured["app"] = app
return app
monkeypatch.setattr(cli, "create_application", fake_create_application)
return captured
def test_existing_account_fast_path_skips_prompting(monkeypatch, tmp_path) -> None:
_set_env(monkeypatch, tmp_path)
LocalAccountStore(tmp_path / "account.json").create(
"operator", "correct horse battery staple"
)
def fail_input(prompt: str = "") -> str:
raise AssertionError("must not prompt when a local account already exists")
monkeypatch.setattr("builtins.input", fail_input)
captured = _patch_create_application(monkeypatch)
cli.main([])
assert captured["app"].ran is True
assert captured["config"].display_name == "operator"
def test_interactive_first_run_prompts_and_creates_account(monkeypatch, tmp_path) -> None:
_set_env(monkeypatch, tmp_path)
monkeypatch.setattr(sys.stdin, "isatty", lambda: True)
inputs = iter(["operator"])
monkeypatch.setattr("builtins.input", lambda prompt="": next(inputs))
passwords = iter(["hunter2", "hunter2"])
monkeypatch.setattr("getpass.getpass", lambda prompt="": next(passwords))
captured = _patch_create_application(monkeypatch)
cli.main([])
assert captured["app"].ran is True
account = LocalAccountStore(tmp_path / "account.json").load()
assert account is not None
assert account.username == "operator"
def test_non_interactive_without_account_exits_with_clear_error(
monkeypatch, tmp_path, capsys
) -> None:
_set_env(monkeypatch, tmp_path)
monkeypatch.setattr(sys.stdin, "isatty", lambda: False)
captured = _patch_create_application(monkeypatch)
with pytest.raises(SystemExit) as exc_info:
cli.main([])
assert exc_info.value.code == 1
assert "setup" in capsys.readouterr().err
assert "app" not in captured
assert LocalAccountStore(tmp_path / "account.json").load() is None
def test_setup_subcommand_creates_account(monkeypatch, tmp_path) -> None:
_set_env(monkeypatch, tmp_path)
inputs = iter(["operator"])
monkeypatch.setattr("builtins.input", lambda prompt="": next(inputs))
passwords = iter(["hunter2", "hunter2"])
monkeypatch.setattr("getpass.getpass", lambda prompt="": next(passwords))
captured = _patch_create_application(monkeypatch)
cli.main(["setup"])
assert "app" not in captured
account = LocalAccountStore(tmp_path / "account.json").load()
assert account is not None
assert account.username == "operator"
def test_setup_subcommand_refuses_overwrite_without_confirmation(
monkeypatch, tmp_path
) -> None:
_set_env(monkeypatch, tmp_path)
store = LocalAccountStore(tmp_path / "account.json")
original = store.create("operator", "original-password")
monkeypatch.setattr("builtins.input", lambda prompt="": "n")
cli.main(["setup"])
assert store.load() == original
@@ -225,3 +225,36 @@ def test_bootstrap_client_retries_identical_enrollment_and_enrolls_device() -> N
assert requests[0].content == requests[1].content assert requests[0].content == requests[1].content
assert requests[0].headers["authorization"] == "Bearer one-time-token" assert requests[0].headers["authorization"] == "Bearer one-time-token"
assert requests[2].headers["authorization"] == ("Bearer host-token-" + ("x" * 40)) assert requests[2].headers["authorization"] == ("Bearer host-token-" + ("x" * 40))
def test_self_service_enrollment_sends_no_authorization_header() -> None:
requests: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
return httpx.Response(201, json={"host_id": "host-cloud-a"})
config = _config(
host_id="",
token="",
enrollment_token="",
enrollment_managed=True,
)
with httpx.Client(
transport=httpx.MockTransport(handler),
base_url="https://control.example",
) as http_client:
client = HostAgentEnrollmentClient(
config,
http_client=http_client,
sleep=lambda _delay: None,
)
host = client.enroll_host(
agent_instance_id="agent-instance-a",
host_token="host-token-" + ("x" * 40),
display_name="operator",
)
assert host.host_id == "host-cloud-a"
assert len(requests) == 1
assert "authorization" not in requests[0].headers
+23 -2
View File
@@ -17,9 +17,9 @@ BASE_ENV = {
} }
def test_load_host_agent_config_uses_local_network_defaults() -> None: def test_load_host_agent_config_uses_managed_cloud_default() -> None:
assert load_host_agent_config(BASE_ENV) == HostAgentConfig( assert load_host_agent_config(BASE_ENV) == HostAgentConfig(
control_plane_url="http://127.0.0.1:8001", control_plane_url="https://amcp.home.jerryyan.top",
host_id="host-a", host_id="host-a",
token="secret", token="secret",
) )
@@ -74,6 +74,27 @@ def test_existing_identity_state_allows_restart_without_enrollment_token(
assert config.enrollment_managed is True assert config.enrollment_managed is True
def test_fresh_install_with_no_token_is_valid_and_defaults_local_account_path() -> None:
config = load_host_agent_config({"HOST_AGENT_CONTROL_PLANE_URL": "https://cloud.example"})
assert config.host_id == ""
assert config.enrollment_token == ""
assert config.enrollment_managed is True
assert config.local_account_path == Path("tasks/host_local_account.json")
def test_local_account_path_can_be_overridden(tmp_path) -> None:
account_path = tmp_path / "account.json"
config = load_host_agent_config(
{
"HOST_AGENT_CONTROL_PLANE_URL": "https://cloud.example",
"HOST_AGENT_LOCAL_ACCOUNT_PATH": str(account_path),
}
)
assert config.local_account_path == account_path
@pytest.mark.parametrize( @pytest.mark.parametrize(
"overrides", "overrides",
[ [
@@ -0,0 +1,93 @@
from __future__ import annotations
from host_agent.client import HostAgentAPIError
from host_agent.config import HostAgentConfig
from host_agent.enrollment import resolve_host_identity
from host_agent.identity import HostIdentityStore
def _config(**overrides) -> HostAgentConfig:
values = {
"control_plane_url": "https://control.example",
}
values.update(overrides)
return HostAgentConfig(**values)
class _RecordingEnrollmentClient:
def __init__(self, *, host_id: str = "host-cloud-a") -> None:
self.host_id = host_id
self.calls: list[dict] = []
def enroll_host(self, **payload):
from cloud.internal_api.models import HostEnrollmentResponse
self.calls.append(payload)
return HostEnrollmentResponse(host_id=self.host_id)
class _RejectingEnrollmentClient:
def enroll_host(self, **payload):
raise HostAgentAPIError(401, "unauthorized")
def test_fresh_install_with_no_token_self_enrolls(tmp_path) -> None:
identity_store = HostIdentityStore(tmp_path / "identity.json")
client = _RecordingEnrollmentClient()
resolved = resolve_host_identity(
_config(enrollment_token=""),
identity_store=identity_store,
client=client,
)
assert resolved.host_id == "host-cloud-a"
assert len(client.calls) == 1
assert identity_store.load().host_id == "host-cloud-a"
def test_self_service_rejection_propagates_as_api_error(tmp_path) -> None:
identity_store = HostIdentityStore(tmp_path / "identity.json")
client = _RejectingEnrollmentClient()
try:
resolve_host_identity(
_config(enrollment_token=""),
identity_store=identity_store,
client=client,
)
except HostAgentAPIError as exc:
assert exc.status_code == 401
else:
raise AssertionError("expected HostAgentAPIError to propagate")
assert identity_store.load().host_id is None
def test_configured_enrollment_token_still_used_when_present(tmp_path) -> None:
identity_store = HostIdentityStore(tmp_path / "identity.json")
client = _RecordingEnrollmentClient()
resolve_host_identity(
_config(enrollment_token="one-time-token"),
identity_store=identity_store,
client=client,
)
assert client.calls[0]["agent_instance_id"]
def test_existing_cached_identity_skips_enrollment(tmp_path) -> None:
identity_store = HostIdentityStore(tmp_path / "identity.json")
identity_store.complete(identity_store.load_or_create(), "host-cloud-a")
class ExplodingClient:
def enroll_host(self, **payload):
raise AssertionError("cached identity must skip enrollment")
resolved = resolve_host_identity(
_config(enrollment_token=""),
identity_store=identity_store,
client=ExplodingClient(), # type: ignore[arg-type]
)
assert resolved.host_id == "host-cloud-a"
@@ -0,0 +1,62 @@
from __future__ import annotations
import os
import pytest
from host_agent.local_account import LocalAccountStateError, LocalAccountStore
def test_local_account_store_creates_and_verifies_password(tmp_path) -> None:
path = tmp_path / "state" / "host_local_account.json"
store = LocalAccountStore(path)
assert store.load() is None
created = store.create("operator", "correct horse battery staple")
assert created.username == "operator"
assert store.load() == created
assert store.verify(created, "correct horse battery staple") is True
assert store.verify(created, "wrong password") is False
raw = path.read_text(encoding="utf-8")
assert "correct horse battery staple" not in raw
assert "password" not in raw.lower() or "password_hash" in raw
if os.name != "nt":
assert path.stat().st_mode & 0o777 == 0o600
def test_local_account_state_never_exposes_password_via_repr(tmp_path) -> None:
store = LocalAccountStore(tmp_path / "host_local_account.json")
created = store.create("operator", "hunter2")
assert "hunter2" not in repr(created)
assert "salt=" not in repr(created)
assert "password_hash=" not in repr(created)
def test_local_account_store_rejects_corrupted_file(tmp_path) -> None:
path = tmp_path / "host_local_account.json"
path.write_text('{"username": "operator"}', encoding="utf-8")
store = LocalAccountStore(path)
with pytest.raises(LocalAccountStateError):
store.load()
def test_local_account_store_rejects_invalid_json(tmp_path) -> None:
path = tmp_path / "host_local_account.json"
path.write_text("not json", encoding="utf-8")
store = LocalAccountStore(path)
with pytest.raises(LocalAccountStateError):
store.load()
def test_local_account_store_rejects_empty_credentials(tmp_path) -> None:
store = LocalAccountStore(tmp_path / "host_local_account.json")
with pytest.raises(ValueError):
store.create("", "password")
with pytest.raises(ValueError):
store.create("operator", "")
+5
View File
@@ -28,6 +28,11 @@ services:
CLOUD_PUBLIC_CREDENTIALS_JSON: ${CLOUD_PUBLIC_CREDENTIALS_JSON} CLOUD_PUBLIC_CREDENTIALS_JSON: ${CLOUD_PUBLIC_CREDENTIALS_JSON}
CLOUD_HOST_CREDENTIALS_JSON: ${CLOUD_HOST_CREDENTIALS_JSON} CLOUD_HOST_CREDENTIALS_JSON: ${CLOUD_HOST_CREDENTIALS_JSON}
CLOUD_ENROLLMENT_TOKENS_JSON: ${CLOUD_ENROLLMENT_TOKENS_JSON:-[]} CLOUD_ENROLLMENT_TOKENS_JSON: ${CLOUD_ENROLLMENT_TOKENS_JSON:-[]}
# Left disabled by default; the amcp.home.jerryyan.top deployment sets
# CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED=true in its own .env (not
# committed here) since it is the intended target for zero-token edge
# enrollment. See docs/CLOUD_DEPLOYMENT.md.
CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED: ${CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED:-false}
CLOUD_SCHEDULER_INTERVAL_SECONDS: ${CLOUD_SCHEDULER_INTERVAL_SECONDS:-1} CLOUD_SCHEDULER_INTERVAL_SECONDS: ${CLOUD_SCHEDULER_INTERVAL_SECONDS:-1}
CLOUD_LEASE_REAPER_INTERVAL_SECONDS: ${CLOUD_LEASE_REAPER_INTERVAL_SECONDS:-5} CLOUD_LEASE_REAPER_INTERVAL_SECONDS: ${CLOUD_LEASE_REAPER_INTERVAL_SECONDS:-5}
CLOUD_LEASE_DURATION_SECONDS: ${CLOUD_LEASE_DURATION_SECONDS:-60} CLOUD_LEASE_DURATION_SECONDS: ${CLOUD_LEASE_DURATION_SECONDS:-60}
+6
View File
@@ -29,6 +29,7 @@ services:
CLOUD_PUBLIC_CREDENTIALS_JSON: ${CLOUD_PUBLIC_CREDENTIALS_JSON} CLOUD_PUBLIC_CREDENTIALS_JSON: ${CLOUD_PUBLIC_CREDENTIALS_JSON}
CLOUD_HOST_CREDENTIALS_JSON: ${CLOUD_HOST_CREDENTIALS_JSON} CLOUD_HOST_CREDENTIALS_JSON: ${CLOUD_HOST_CREDENTIALS_JSON}
CLOUD_ENROLLMENT_TOKENS_JSON: ${CLOUD_ENROLLMENT_TOKENS_JSON:-[]} CLOUD_ENROLLMENT_TOKENS_JSON: ${CLOUD_ENROLLMENT_TOKENS_JSON:-[]}
CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED: ${CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED:-false}
CLOUD_SCHEDULER_INTERVAL_SECONDS: ${CLOUD_SCHEDULER_INTERVAL_SECONDS:-1} CLOUD_SCHEDULER_INTERVAL_SECONDS: ${CLOUD_SCHEDULER_INTERVAL_SECONDS:-1}
CLOUD_LEASE_REAPER_INTERVAL_SECONDS: ${CLOUD_LEASE_REAPER_INTERVAL_SECONDS:-5} CLOUD_LEASE_REAPER_INTERVAL_SECONDS: ${CLOUD_LEASE_REAPER_INTERVAL_SECONDS:-5}
CLOUD_LEASE_DURATION_SECONDS: ${CLOUD_LEASE_DURATION_SECONDS:-60} CLOUD_LEASE_DURATION_SECONDS: ${CLOUD_LEASE_DURATION_SECONDS:-60}
@@ -53,6 +54,10 @@ services:
retries: 12 retries: 12
restart: unless-stopped restart: unless-stopped
# Before the first `docker compose up`, create the one-time local operator
# account interactively: `docker compose run --rm host-agent device-host-agent setup`.
# The daemon's default command below has no TTY and fails fast if that
# account does not already exist under HOST_AGENT_LOCAL_ACCOUNT_PATH.
host-agent: host-agent:
build: build:
context: . context: .
@@ -63,6 +68,7 @@ services:
HOST_AGENT_TOKEN: ${HOST_AGENT_TOKEN} HOST_AGENT_TOKEN: ${HOST_AGENT_TOKEN}
HOST_AGENT_ENROLLMENT_TOKEN: ${HOST_AGENT_ENROLLMENT_TOKEN:-} HOST_AGENT_ENROLLMENT_TOKEN: ${HOST_AGENT_ENROLLMENT_TOKEN:-}
HOST_AGENT_IDENTITY_PATH: ${HOST_AGENT_IDENTITY_PATH:-/app/tasks/host_identity.json} HOST_AGENT_IDENTITY_PATH: ${HOST_AGENT_IDENTITY_PATH:-/app/tasks/host_identity.json}
HOST_AGENT_LOCAL_ACCOUNT_PATH: ${HOST_AGENT_LOCAL_ACCOUNT_PATH:-/app/tasks/host_local_account.json}
HOST_AGENT_DISPLAY_NAME: ${HOST_AGENT_DISPLAY_NAME:-} HOST_AGENT_DISPLAY_NAME: ${HOST_AGENT_DISPLAY_NAME:-}
HOST_AGENT_HEARTBEAT_INTERVAL_SECONDS: ${HOST_AGENT_HEARTBEAT_INTERVAL_SECONDS:-30} HOST_AGENT_HEARTBEAT_INTERVAL_SECONDS: ${HOST_AGENT_HEARTBEAT_INTERVAL_SECONDS:-30}
HOST_AGENT_POLL_TIMEOUT_SECONDS: ${HOST_AGENT_POLL_TIMEOUT_SECONDS:-20} HOST_AGENT_POLL_TIMEOUT_SECONDS: ${HOST_AGENT_POLL_TIMEOUT_SECONDS:-20}
+44
View File
@@ -84,6 +84,46 @@ 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 previous legacy behavior, including locally selected device IDs. This is
the rollback and staged-migration path for existing deployments. the rollback and staged-migration path for existing deployments.
## Self-Service Edge Enrollment (Zero-Token)
The Host Agent's default `HOST_AGENT_CONTROL_PLANE_URL` is
`https://amcp.home.jerryyan.top`. This is a single-operator home deployment
default; override the environment variable for local/dev/test runs pointed at
a different Cloud API.
When `HOST_AGENT_ENROLLMENT_TOKEN` is not set and no cached identity exists,
the Host Agent enrolls with no bearer credential at all. The Cloud API only
accepts that request when `CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED=true`
(default `false`); a configured enrollment token, if presented, always takes
priority over self-service. This trades away any approval step: **any caller
that can reach the control-plane URL can register itself as a new Host.**
There is no rate limiting or throttling on this path by design — the intended
mitigation is network-perimeter control (firewall/reverse-proxy access to the
URL), not an in-process limiter. Only enable the flag on a deployment where
that network boundary is enforced.
Before the Host Agent's first unattended start, create the one-time local
operator account interactively:
```bash
uv run --package device-host-agent device-host-agent setup
```
This prompts for a username/password and writes a PBKDF2-hashed credential
file to `HOST_AGENT_LOCAL_ACCOUNT_PATH` (default
`tasks/host_local_account.json`), gating only this first-run bootstrap step —
it is not re-checked on subsequent unattended restarts. Running the daemon's
default command without a controlling terminal before this file exists fails
fast with a message naming the `setup` step, instead of hanging on a prompt
no one can answer.
Running under Compose, create the account once before `docker compose up`:
```bash
docker compose run --rm host-agent device-host-agent setup
docker compose up -d
```
## PostgreSQL Deployment ## PostgreSQL Deployment
Start from `.env.example`, replace every `change-me-*` value, and keep the Start from `.env.example`, replace every `change-me-*` value, and keep the
@@ -154,6 +194,10 @@ renewal, and result operations for that host.
pool, or operate as a Host; they can only create one durable Host binding. pool, or operate as a Host; they can only create one durable Host binding.
Use high-entropy values generated by the deployment secret manager. Use high-entropy values generated by the deployment secret manager.
`CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED` (default `false`) additionally accepts
Host enrollment requests with no bearer token at all — see
[Self-Service Edge Enrollment](#self-service-edge-enrollment-zero-token).
Do not place bearer tokens in command history, image layers, Compose files, or Do not place bearer tokens in command history, image layers, Compose files, or
logs. Use environment injection or the deployment platform's secret manager. 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 Rotate a token by deploying the updated Cloud API credential set and Host Agent
+20 -6
View File
@@ -367,11 +367,21 @@ DeviceConfigStore("tasks/device_config.sqlite3").add(
PY PY
``` ```
从云端管理员获取一枚未使用的一次性 enrollment token,然后启动: 首次启动前,先在交互式终端创建一次性本地操作账号(仅用于门禁"首次启动"这个
动作本身,后续无人值守重启不会再次要求):
```bash
uv run --package device-host-agent device-host-agent setup
```
`HOST_AGENT_CONTROL_PLANE_URL` 默认已固定为 `https://amcp.home.jerryyan.top`
仅在连接其他云端环境(如本地/测试用的 `cloud-api`)时才需要覆盖该变量。未设置
`HOST_AGENT_ENROLLMENT_TOKEN` 时,Host Agent 会直接向云端发起零 token 自助注册
(需要云端 `CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED=true`);若仍持有云端管理员
签发的一次性 enrollment token,也可以继续设置 `HOST_AGENT_ENROLLMENT_TOKEN`
走原有的 token 注册路径:
```bash ```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_IDENTITY_PATH="tasks/host_identity.json"
export HOST_AGENT_DISPLAY_NAME="Edge Mac 01" export HOST_AGENT_DISPLAY_NAME="Edge Mac 01"
@@ -384,9 +394,13 @@ uv run --package device-host-agent device-host-agent
首次启动顺序为:持久化候选 Host secret、向云端换取 `host_id`、为每个本地设备 首次启动顺序为:持久化候选 Host secret、向云端换取 `host_id`、为每个本地设备
换取 `device_id`、保存映射、连接 WDA、发送 heartbeat、开始 long-poll 领取任务。 换取 `device_id`、保存映射、连接 WDA、发送 heartbeat、开始 long-poll 领取任务。
Host Agent 不监听入站端口。成功后可以从边缘环境移除 enrollment token,但必须 Host Agent 不监听入站端口。若使用了一次性 enrollment token,成功后可以将其从
保留并保护 `tasks/host_identity.json` `tasks/device_config.sqlite3`;前者等同于 边缘环境移除;但必须保留并保护 `tasks/host_identity.json`
Host bearer credential。 `tasks/device_config.sqlite3`;前者等同于 Host bearer credential。
零 token 自助注册意味着任何能访问该云端地址的设备都能自行注册成为 Host,没有
审批环节,也没有限流保护;这一取舍依赖网络边界(防火墙/反向代理)而非应用层
限制,仅应在受控网络中开启 `CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED`
需要回滚到静态模式时,停止 Host Agent,由云端管理员配置匹配的静态 Host 需要回滚到静态模式时,停止 Host Agent,由云端管理员配置匹配的静态 Host
credential,然后显式设置 `HOST_AGENT_HOST_ID``HOST_AGENT_TOKEN`。这两个变量 credential,然后显式设置 `HOST_AGENT_HOST_ID``HOST_AGENT_TOKEN`。这两个变量
@@ -1,46 +1,46 @@
## 1. Host Agent local account storage ## 1. Host Agent local account storage
- [ ] 1.1 Add `host_agent/local_account.py` with `LocalAccountState` (username, salt, iterations, password_hash) and `LocalAccountStore` (load/create), reusing `identity.py`'s atomic temp-file-then-`os.replace` write and `chmod 0600` pattern - [x] 1.1 Add `host_agent/local_account.py` with `LocalAccountState` (username, salt, iterations, password_hash) and `LocalAccountStore` (load/create), reusing `identity.py`'s atomic temp-file-then-`os.replace` write and `chmod 0600` pattern
- [ ] 1.2 Implement PBKDF2-HMAC-SHA256 hashing (`hashlib.pbkdf2_hmac`, 600,000 iterations, `secrets.token_bytes(16)` salt) and `hmac.compare_digest`-based verification - [x] 1.2 Implement PBKDF2-HMAC-SHA256 hashing (`hashlib.pbkdf2_hmac`, 600,000 iterations, `secrets.token_bytes(16)` salt) and `hmac.compare_digest`-based verification
- [ ] 1.3 Add `HOST_AGENT_LOCAL_ACCOUNT_PATH` to `config.py::HostAgentConfig`/`load_host_agent_config` with a default alongside the existing `tasks/` state directory (e.g. `tasks/host_local_account.json`) - [x] 1.3 Add `HOST_AGENT_LOCAL_ACCOUNT_PATH` to `config.py::HostAgentConfig`/`load_host_agent_config` with a default alongside the existing `tasks/` state directory (e.g. `tasks/host_local_account.json`)
- [ ] 1.4 Add unit tests: file creation is atomic and `0600`, corrupted/invalid file raises a clear error, password hash roundtrips correctly, no plaintext password ever appears in the persisted file or in `repr()`/logging paths - [x] 1.4 Add unit tests: file creation is atomic and `0600`, corrupted/invalid file raises a clear error, password hash roundtrips correctly, no plaintext password ever appears in the persisted file or in `repr()`/logging paths
## 2. Host Agent CLI first-run bootstrap ## 2. Host Agent CLI first-run bootstrap
- [ ] 2.1 Add a `setup` subcommand to `cli.py` (argparse subparsers) that prompts via `getpass.getpass` for username/password and creates the local account if none exists, refusing to overwrite an existing account without explicit confirmation - [x] 2.1 Add a `setup` subcommand to `cli.py` (argparse subparsers) that prompts via `getpass.getpass` for username/password and creates the local account if none exists, refusing to overwrite an existing account without explicit confirmation
- [ ] 2.2 Change the default (no subcommand) path in `cli.py`/`app.py::create_application` to check for the local account before starting: if present, proceed unchanged; if absent and `sys.stdin.isatty()`, prompt inline; if absent and not a TTY, exit with a clear error naming the `setup` subcommand - [x] 2.2 Change the default (no subcommand) path in `cli.py`/`app.py::create_application` to check for the local account before starting: if present, proceed unchanged; if absent and `sys.stdin.isatty()`, prompt inline; if absent and not a TTY, exit with a clear error naming the `setup` subcommand
- [ ] 2.3 Add tests covering: existing-account fast path, interactive TTY prompt path (mocked), non-interactive no-account failure path, and the `setup` subcommand itself - [x] 2.3 Add tests covering: existing-account fast path, interactive TTY prompt path (mocked), non-interactive no-account failure path, and the `setup` subcommand itself
## 3. Host Agent enrollment fallback and fixed control-plane URL ## 3. Host Agent enrollment fallback and fixed control-plane URL
- [ ] 3.1 Change `config.py::load_host_agent_config`'s `HOST_AGENT_CONTROL_PLANE_URL` default to `https://amcp.home.jerryyan.top`, keeping the existing `urlparse` validation and environment-variable override behavior - [x] 3.1 Change `config.py::load_host_agent_config`'s `HOST_AGENT_CONTROL_PLANE_URL` default to `https://amcp.home.jerryyan.top`, keeping the existing `urlparse` validation and environment-variable override behavior
- [ ] 3.2 Remove the `HostAgentConfigurationError` raised when no host_id/token, no enrollment token, and no identity file are present; a missing enrollment token is no longer a startup configuration error - [x] 3.2 Remove the `HostAgentConfigurationError` raised when no host_id/token, no enrollment token, and no identity file are present; a missing enrollment token is no longer a startup configuration error
- [ ] 3.3 Update `enrollment.py::resolve_host_identity` to call the enrollment client with no bearer credential when `config.enrollment_token` is empty, instead of raising - [x] 3.3 Update `enrollment.py::resolve_host_identity` to call the enrollment client with no bearer credential when `config.enrollment_token` is empty, instead of raising
- [ ] 3.4 Update `client.py::HostAgentEnrollmentClient.enroll_host` (or equivalent) to support an unauthenticated (no `Authorization` header) enrollment request path, and to pass the local account username as `display_name` when `HOST_AGENT_DISPLAY_NAME` is unset - [x] 3.4 Update `client.py::HostAgentEnrollmentClient.enroll_host` (or equivalent) to support an unauthenticated (no `Authorization` header) enrollment request path, and to pass the local account username as `display_name` when `HOST_AGENT_DISPLAY_NAME` is unset
- [ ] 3.5 Add/update tests: fresh install with no token self-enrolls successfully (mocked cloud response), self-service rejection (`401` from cloud) surfaces as a clear startup failure and does not start polling, configured `HOST_AGENT_ENROLLMENT_TOKEN` still takes the existing token-bound path unchanged, existing cached identity skips enrollment entirely - [x] 3.5 Add/update tests: fresh install with no token self-enrolls successfully (mocked cloud response), self-service rejection (`401` from cloud) surfaces as a clear startup failure and does not start polling, configured `HOST_AGENT_ENROLLMENT_TOKEN` still takes the existing token-bound path unchanged, existing cached identity skips enrollment entirely
## 4. Cloud self-service enrollment configuration and auth ## 4. Cloud self-service enrollment configuration and auth
- [ ] 4.1 Add `CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED` (bool, default `false`) to `control_config.py::CloudControlConfig`/`load_control_config` - [x] 4.1 Add `CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED` (bool, default `false`) to `control_config.py::CloudControlConfig`/`load_control_config`
- [ ] 4.2 Add `SelfServiceEnrollmentAuthProvider` to `auth.py`, returning a fixed `EnrollmentPrincipal(id="self-service", token_digest=None)` unconditionally - [x] 4.2 Add `SelfServiceEnrollmentAuthProvider` to `auth.py`, returning a fixed `EnrollmentPrincipal(id="self-service", token_digest=None)` unconditionally
- [ ] 4.3 Add an ordered enrollment-auth chain (configured-token provider first, self-service provider second when enabled) and wire it into `apps/cloud-api/cloud_api/app.py::create_app()` in place of the single `ConfiguredEnrollmentTokenProvider` - [x] 4.3 Add an ordered enrollment-auth chain (configured-token provider first, self-service provider second when enabled) and wire it into `apps/cloud-api/cloud_api/app.py::create_app()` in place of the single `ConfiguredEnrollmentTokenProvider`
- [ ] 4.4 Add tests: self-service enabled + no bearer token enrolls successfully with `enrollment_token_digest = NULL`; self-service disabled + no bearer token still returns `401` (current behavior unchanged); self-service enabled + a valid configured token still uses the token-bound path with existing conflict/idempotency semantics; self-service enabled + an invalid/unknown token still falls through to the self-service principal (since only a *presented and mismatched* token, or none at all, should reach self-service — confirm and encode the exact fallback condition from design.md D3) - [x] 4.4 Add tests: self-service enabled + no bearer token enrolls successfully with `enrollment_token_digest = NULL`; self-service disabled + no bearer token still returns `401` (current behavior unchanged); self-service enabled + a valid configured token still uses the token-bound path with existing conflict/idempotency semantics; self-service enabled + an invalid/unknown token still falls through to the self-service principal (since only a *presented and mismatched* token, or none at all, should reach self-service — confirm and encode the exact fallback condition from design.md D3)
## 5. Cloud enrollment idempotency and conflict behavior verification ## 5. Cloud enrollment idempotency and conflict behavior verification
- [ ] 5.1 Add repository-level tests confirming `sql_repository.py::enroll_host` idempotent-retry-by-`agent_instance_id` behavior works correctly when `enrollment_token_digest` is `NULL` (repeat self-service enrollment from the same instance returns the same `host_id`) - [x] 5.1 Add repository-level tests confirming `sql_repository.py::enroll_host` idempotent-retry-by-`agent_instance_id` behavior works correctly when `enrollment_token_digest` is `NULL` (repeat self-service enrollment from the same instance returns the same `host_id`)
- [ ] 5.2 Add repository-level tests confirming multiple distinct self-service Hosts (each with `enrollment_token_digest = NULL`) can coexist without violating the unique constraint on that column, for both SQLite and PostgreSQL - [x] 5.2 Add repository-level tests confirming multiple distinct self-service Hosts (each with `enrollment_token_digest = NULL`) can coexist without violating the unique constraint on that column, for both SQLite and PostgreSQL
## 6. Deployment and documentation ## 6. Deployment and documentation
- [ ] 6.1 Update `.env.example` with `CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED` (documented default `false`) and the new default `HOST_AGENT_CONTROL_PLANE_URL` behavior - [x] 6.1 Update `.env.example` with `CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED` (documented default `false`) and the new default `HOST_AGENT_CONTROL_PLANE_URL` behavior
- [ ] 6.2 Update `compose.yaml`/`compose.deploy.yaml` examples to show the flag left disabled by default, with a comment on how the `amcp.home.jerryyan.top` deployment enables it - [x] 6.2 Update `compose.yaml`/`compose.deploy.yaml` examples to show the flag left disabled by default, with a comment on how the `amcp.home.jerryyan.top` deployment enables it
- [ ] 6.3 Update `docs/CLOUD_DEPLOYMENT.md` with the self-service enrollment flag, its security implications, and rollback steps - [x] 6.3 Update `docs/CLOUD_DEPLOYMENT.md` with the self-service enrollment flag, its security implications, and rollback steps
- [ ] 6.4 Update `docs/MACOS_IPHONE_SETUP.md` with the new `device-host-agent setup` first-run step - [x] 6.4 Update `docs/MACOS_IPHONE_SETUP.md` with the new `device-host-agent setup` first-run step
## 7. Verification ## 7. Verification
- [ ] 7.1 Run formatting, lint, and the full non-integration test suite across the workspace (`uv run --all-packages pytest -m "not integration"`) - [x] 7.1 Run formatting, lint, and the full non-integration test suite across the workspace (`uv run --all-packages pytest -m "not integration"`) — 494 passed; no lint/formatter (ruff/mypy) is configured in this repo, so only the test suite ran
- [ ] 7.2 Run the PostgreSQL-backed repository tests for the new nullable-`enrollment_token_digest` self-service paths - [ ] 7.2 Run the PostgreSQL-backed repository tests for the new nullable-`enrollment_token_digest` self-service paths — not run: no `TEST_POSTGRES_URL`/Docker available in this environment; the SQLite side of the same parametrized tests (`tests/test_cloud_repository_contract.py`) passed
- [ ] 7.3 Run `openspec validate edge-host-self-enrollment --strict` and resolve all artifact/spec errors - [x] 7.3 Run `openspec validate edge-host-self-enrollment --strict` and resolve all artifact/spec errors — passed
- [ ] 7.4 Manually verify end-to-end: fresh Host Agent install, `device-host-agent setup`, then `device-host-agent` self-enrolls against a cloud-api instance with `CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED=true`, and heartbeat/device-status reporting continues on the expected interval - [ ] 7.4 Manually verify end-to-end: fresh Host Agent install, `device-host-agent setup`, then `device-host-agent` self-enrolls against a cloud-api instance with `CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED=true`, and heartbeat/device-status reporting continues on the expected interval — not performed: no real cloud-api deployment reachable from this environment; covered by automated integration-style tests instead (`test_enrollment.py`, `test_app.py`, `test_e2e.py`)
+25 -1
View File
@@ -99,7 +99,12 @@ class EnrollmentCredential:
@dataclass(frozen=True) @dataclass(frozen=True)
class EnrollmentPrincipal: class EnrollmentPrincipal:
id: str id: str
token_digest: str = field(repr=False) token_digest: str | None = field(default=None, repr=False)
@runtime_checkable
class EnrollmentAuthProvider(Protocol):
def authenticate(self, request: object) -> EnrollmentPrincipal | None: ...
class ConfiguredBearerAuthProvider: class ConfiguredBearerAuthProvider:
@@ -156,6 +161,25 @@ class ConfiguredEnrollmentTokenProvider:
return matched return matched
class SelfServiceEnrollmentAuthProvider:
"""Unconditionally authorizes Host enrollment with no pre-issued token."""
def authenticate(self, request: object) -> EnrollmentPrincipal | None:
return EnrollmentPrincipal(id="self-service", token_digest=None)
class ChainedEnrollmentAuthProvider:
def __init__(self, providers: Iterable[EnrollmentAuthProvider]) -> None:
self.providers = tuple(providers)
def authenticate(self, request: object) -> EnrollmentPrincipal | None:
for provider in self.providers:
principal = provider.authenticate(request)
if principal is not None:
return principal
return None
class RepositoryHostAuthProvider: class RepositoryHostAuthProvider:
def __init__(self, repository: CloudRepository) -> None: def __init__(self, repository: CloudRepository) -> None:
self.repository = repository self.repository = repository
@@ -32,6 +32,7 @@ class CloudControlConfig:
allow_insecure_anonymous: bool = False allow_insecure_anonymous: bool = False
credentials: tuple[BearerCredential, ...] = () credentials: tuple[BearerCredential, ...] = ()
enrollment_credentials: tuple[EnrollmentCredential, ...] = () enrollment_credentials: tuple[EnrollmentCredential, ...] = ()
self_service_enrollment_enabled: bool = False
cors_allowed_origins: tuple[str, ...] = () cors_allowed_origins: tuple[str, ...] = ()
console_static_dir: str | None = None console_static_dir: str | None = None
user_session_idle_seconds: int = 28_800 user_session_idle_seconds: int = 28_800
@@ -99,6 +100,10 @@ def load_control_config(
enrollment_credentials=_parse_enrollment_credentials( enrollment_credentials=_parse_enrollment_credentials(
values.get("CLOUD_ENROLLMENT_TOKENS_JSON") values.get("CLOUD_ENROLLMENT_TOKENS_JSON")
), ),
self_service_enrollment_enabled=_parse_bool(
values.get("CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED"),
default=False,
),
cors_allowed_origins=_parse_cors_origins( cors_allowed_origins=_parse_cors_origins(
values.get("CLOUD_CONSOLE_CORS_ORIGINS") values.get("CLOUD_CONSOLE_CORS_ORIGINS")
), ),
@@ -13,6 +13,7 @@ from fastapi.responses import JSONResponse
from cloud.auth import ( from cloud.auth import (
AuthProvider, AuthProvider,
ConfiguredEnrollmentTokenProvider, ConfiguredEnrollmentTokenProvider,
EnrollmentAuthProvider,
HostAuthorizationError, HostAuthorizationError,
digest_token, digest_token,
) )
@@ -47,7 +48,7 @@ def create_internal_router(
*, *,
pool: DevicePool, pool: DevicePool,
auth_provider: AuthProvider, auth_provider: AuthProvider,
enrollment_auth_provider: ConfiguredEnrollmentTokenProvider | None = None, enrollment_auth_provider: EnrollmentAuthProvider | None = None,
version_prefix: str = "/internal/v1", version_prefix: str = "/internal/v1",
claim_poll_interval_seconds: float = 0.1, claim_poll_interval_seconds: float = 0.1,
lease_duration_seconds: float = 60.0, lease_duration_seconds: float = 60.0,
+1 -1
View File
@@ -100,7 +100,7 @@ class CloudRepository(Protocol):
host_id: str, host_id: str,
agent_instance_id: str, agent_instance_id: str,
credential_digest: str, credential_digest: str,
enrollment_token_digest: str, enrollment_token_digest: str | None,
display_name: str | None, display_name: str | None,
enrolled_at: datetime, enrolled_at: datetime,
) -> HostEnrollment: ... ) -> HostEnrollment: ...
+23 -11
View File
@@ -45,7 +45,7 @@ class SQLAlchemyCloudRepository:
host_id: str, host_id: str,
agent_instance_id: str, agent_instance_id: str,
credential_digest: str, credential_digest: str,
enrollment_token_digest: str, enrollment_token_digest: str | None,
display_name: str | None, display_name: str | None,
enrolled_at: datetime, enrolled_at: datetime,
) -> Any: ) -> Any:
@@ -72,11 +72,19 @@ class SQLAlchemyCloudRepository:
) )
return _host_enrollment_from_row(existing) return _host_enrollment_from_row(existing)
token_owner = session.scalars( # A NULL enrollment_token_digest marks self-service Hosts, which
select(HostRow).where( # never bind a shared token; comparing `== NULL` would otherwise
HostRow.enrollment_token_digest == enrollment_token_digest # match every other self-service Host via SQL's `IS NULL` and
) # falsely report a token conflict.
).first() token_owner = (
session.scalars(
select(HostRow).where(
HostRow.enrollment_token_digest == enrollment_token_digest
)
).first()
if enrollment_token_digest is not None
else None
)
if token_owner is not None: if token_owner is not None:
raise EnrollmentTokenConflictError( raise EnrollmentTokenConflictError(
"enrollment token is already bound to another Host" "enrollment token is already bound to another Host"
@@ -109,11 +117,15 @@ class SQLAlchemyCloudRepository:
and existing.enrollment_token_digest == enrollment_token_digest and existing.enrollment_token_digest == enrollment_token_digest
): ):
return _host_enrollment_from_row(existing) return _host_enrollment_from_row(existing)
token_owner = session.scalars( token_owner = (
select(HostRow).where( session.scalars(
HostRow.enrollment_token_digest == enrollment_token_digest select(HostRow).where(
) HostRow.enrollment_token_digest == enrollment_token_digest
).first() )
).first()
if enrollment_token_digest is not None
else None
)
if token_owner is not None: if token_owner is not None:
raise EnrollmentTokenConflictError( raise EnrollmentTokenConflictError(
"enrollment token is already bound to another Host" "enrollment token is already bound to another Host"
+66
View File
@@ -7,6 +7,7 @@ import pytest
from cloud.auth import ( from cloud.auth import (
BearerCredential, BearerCredential,
ChainedAuthProvider, ChainedAuthProvider,
ChainedEnrollmentAuthProvider,
ConfiguredBearerAuthProvider, ConfiguredBearerAuthProvider,
ConfiguredEnrollmentTokenProvider, ConfiguredEnrollmentTokenProvider,
EnrollmentCredential, EnrollmentCredential,
@@ -14,6 +15,7 @@ from cloud.auth import (
HostPrincipalRequiredError, HostPrincipalRequiredError,
NullAuthProvider, NullAuthProvider,
RepositoryHostAuthProvider, RepositoryHostAuthProvider,
SelfServiceEnrollmentAuthProvider,
digest_token, digest_token,
) )
@@ -213,3 +215,67 @@ def test_repository_host_auth_and_chain_preserve_host_scope() -> None:
assert host_principal is not None assert host_principal is not None
assert host_principal.host_id == "host-managed" assert host_principal.host_id == "host-managed"
assert host_principal.scopes == frozenset() assert host_principal.scopes == frozenset()
def test_self_service_enabled_with_no_token_enrolls_via_self_service() -> None:
provider = ChainedEnrollmentAuthProvider(
[ConfiguredEnrollmentTokenProvider(()), SelfServiceEnrollmentAuthProvider()]
)
principal = provider.authenticate(_Request(headers={}))
assert principal is not None
assert principal.id == "self-service"
assert principal.token_digest is None
def test_self_service_disabled_with_no_token_is_unauthenticated() -> None:
provider = ChainedEnrollmentAuthProvider([ConfiguredEnrollmentTokenProvider(())])
assert provider.authenticate(_Request(headers={})) is None
def test_self_service_enabled_with_valid_configured_token_uses_token_bound_path() -> (
None
):
credential = EnrollmentCredential(
principal_id="installer-a",
token="one-time-enrollment-secret",
)
provider = ChainedEnrollmentAuthProvider(
[
ConfiguredEnrollmentTokenProvider([credential]),
SelfServiceEnrollmentAuthProvider(),
]
)
principal = provider.authenticate(
_Request(headers={"authorization": "Bearer one-time-enrollment-secret"})
)
assert principal is not None
assert principal.id == "installer-a"
assert principal.token_digest == digest_token("one-time-enrollment-secret")
def test_self_service_enabled_with_unknown_token_falls_through_to_self_service() -> (
None
):
credential = EnrollmentCredential(
principal_id="installer-a",
token="one-time-enrollment-secret",
)
provider = ChainedEnrollmentAuthProvider(
[
ConfiguredEnrollmentTokenProvider([credential]),
SelfServiceEnrollmentAuthProvider(),
]
)
principal = provider.authenticate(
_Request(headers={"authorization": "Bearer unknown-secret"})
)
assert principal is not None
assert principal.id == "self-service"
assert principal.token_digest is None
+68
View File
@@ -159,6 +159,74 @@ def test_host_enrollment_is_idempotent_and_token_is_one_time(
database.close() database.close()
def test_self_service_enrollment_is_idempotent_with_null_token_digest(
database_url: str,
) -> None:
database = CloudDatabase(database_url)
repository = database.repository
enrolled_at = datetime(2026, 7, 13, 6, 0, tzinfo=UTC)
host_id = _unique_id("self-service-host")
agent_instance_id = _unique_id("self-service-instance")
credential_digest = _unique_id("self-service-credential")
try:
created = repository.enroll_host(
host_id=host_id,
agent_instance_id=agent_instance_id,
credential_digest=credential_digest,
enrollment_token_digest=None,
display_name="Self-Service Host",
enrolled_at=enrolled_at,
)
assert created.host_id == 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=None,
display_name="Renamed Self-Service Host",
enrolled_at=enrolled_at + timedelta(minutes=1),
)
assert retried == created
finally:
database.close()
def test_multiple_self_service_hosts_coexist_without_token_conflict(
database_url: str,
) -> None:
database = CloudDatabase(database_url)
repository = database.repository
enrolled_at = datetime(2026, 7, 13, 7, 0, tzinfo=UTC)
credential_a = _unique_id("self-service-credential-a")
credential_b = _unique_id("self-service-credential-b")
try:
first = repository.enroll_host(
host_id=_unique_id("self-service-host-a"),
agent_instance_id=_unique_id("self-service-instance-a"),
credential_digest=credential_a,
enrollment_token_digest=None,
display_name=None,
enrolled_at=enrolled_at,
)
second = repository.enroll_host(
host_id=_unique_id("self-service-host-b"),
agent_instance_id=_unique_id("self-service-instance-b"),
credential_digest=credential_b,
enrollment_token_digest=None,
display_name=None,
enrolled_at=enrolled_at,
)
assert first.host_id != second.host_id
assert repository.authenticate_enrolled_host(credential_a) == first.host_id
assert repository.authenticate_enrolled_host(credential_b) == second.host_id
finally:
database.close()
def test_device_enrollment_is_host_scoped_and_idempotent( def test_device_enrollment_is_host_scoped_and_idempotent(
database_url: str, database_url: str,
) -> None: ) -> None: