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
+66
View File
@@ -7,6 +7,7 @@ import pytest
from cloud.auth import (
BearerCredential,
ChainedAuthProvider,
ChainedEnrollmentAuthProvider,
ConfiguredBearerAuthProvider,
ConfiguredEnrollmentTokenProvider,
EnrollmentCredential,
@@ -14,6 +15,7 @@ from cloud.auth import (
HostPrincipalRequiredError,
NullAuthProvider,
RepositoryHostAuthProvider,
SelfServiceEnrollmentAuthProvider,
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.host_id == "host-managed"
assert host_principal.scopes == frozenset()
def test_self_service_enabled_with_no_token_enrolls_via_self_service() -> None:
provider = ChainedEnrollmentAuthProvider(
[ConfiguredEnrollmentTokenProvider(()), SelfServiceEnrollmentAuthProvider()]
)
principal = provider.authenticate(_Request(headers={}))
assert principal is not None
assert principal.id == "self-service"
assert principal.token_digest is None
def test_self_service_disabled_with_no_token_is_unauthenticated() -> None:
provider = ChainedEnrollmentAuthProvider([ConfiguredEnrollmentTokenProvider(())])
assert provider.authenticate(_Request(headers={})) is None
def test_self_service_enabled_with_valid_configured_token_uses_token_bound_path() -> (
None
):
credential = EnrollmentCredential(
principal_id="installer-a",
token="one-time-enrollment-secret",
)
provider = ChainedEnrollmentAuthProvider(
[
ConfiguredEnrollmentTokenProvider([credential]),
SelfServiceEnrollmentAuthProvider(),
]
)
principal = provider.authenticate(
_Request(headers={"authorization": "Bearer one-time-enrollment-secret"})
)
assert principal is not None
assert principal.id == "installer-a"
assert principal.token_digest == digest_token("one-time-enrollment-secret")
def test_self_service_enabled_with_unknown_token_falls_through_to_self_service() -> (
None
):
credential = EnrollmentCredential(
principal_id="installer-a",
token="one-time-enrollment-secret",
)
provider = ChainedEnrollmentAuthProvider(
[
ConfiguredEnrollmentTokenProvider([credential]),
SelfServiceEnrollmentAuthProvider(),
]
)
principal = provider.authenticate(
_Request(headers={"authorization": "Bearer unknown-secret"})
)
assert principal is not None
assert principal.id == "self-service"
assert principal.token_digest is None
+68
View File
@@ -159,6 +159,74 @@ def test_host_enrollment_is_idempotent_and_token_is_one_time(
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(
database_url: str,
) -> None: