Files
agentic-mobile-control/openspec/changes/edge-host-self-enrollment/design.md
T
2026-07-13 17:55:40 +08:00

12 KiB

Context

Two systems are affected:

  • Host Agent (apps/device-host-agent/host_agent/): cli.py::main() currently just calls create_application().run(). config.py::load_host_agent_config() defaults HOST_AGENT_CONTROL_PLANE_URL to http://127.0.0.1:8001 and raises HostAgentConfigurationError unless one of (static HOST_AGENT_HOST_ID+HOST_AGENT_TOKEN, HOST_AGENT_ENROLLMENT_TOKEN, or an existing identity file at HOST_AGENT_IDENTITY_PATH) is present. enrollment.py::resolve_host_identity raises if HOST_AGENT_ENROLLMENT_TOKEN is empty and no cached host_id exists. identity.py::HostIdentityStore already establishes the atomic-write + chmod 0600 pattern this change reuses for the new local account file.
  • Cloud Control Plane (packages/cloud-platform/cloud/, apps/cloud-api/): internal_api/api.py::enroll_host() authenticates every enrollment request via enrollment_auth.authenticate(request) (ConfiguredEnrollmentTokenProvider, built from CLOUD_ENROLLMENT_TOKENS_JSON), returning 401 if no configured token digest matches the bearer header. sql_repository.py::enroll_host() first checks for an existing HostRow by agent_instance_id (idempotent retry, independent of the token) before checking whether the presented enrollment_token_digest is already bound to another host. Both credential_digest and enrollment_token_digest columns on HostRow (db_models.py) are already nullable — no migration is needed to store a NULL enrollment_token_digest.

This is a single-operator home deployment (https://amcp.home.jerryyan.top). The user has explicitly accepted that self-service enrollment means any network caller reaching that URL can register itself as a Host with no approval step, and asked that this not require a distribution/whitelist mechanism.

Goals / Non-Goals

Goals:

  • A fresh Host Agent install can complete first-run local setup and enroll against the cloud with zero pre-shared enrollment token, using a fixed default control-plane URL.
  • Existing static-credential and token-based enrollment deployments keep working unchanged (edge-host-enrollment behavior is additive, not replaced).
  • The local account gate is a one-time, interactive setup step, not an ongoing authentication mechanism for the unattended daemon.

Non-Goals:

  • No multi-user account system, no roles, no network-facing login surface for the Host Agent. This is unrelated to the separate (unimplemented) cloud-console-user-authentication proposal's cloud-operator user model — different system, different threat model, not to be unified here.
  • No re-authentication on every daemon start/restart. Once the local account file exists, the daemon starts unattended (required for systemd/launchd-managed services).
  • No approval workflow, admin review queue, or per-request throttling/rate-limiting for self-service enrollment. The chosen mitigation for this deployment is network-perimeter control (the operator's own reverse proxy/firewall in front of amcp.home.jerryyan.top), not application-level throttling — see Risks.
  • No removal of CLOUD_ENROLLMENT_TOKENS_JSON, static Host credentials, or the revocation path from edge-host-enrollment.

Decisions

D1. Local account credential storage: stdlib PBKDF2-HMAC-SHA256, not a new dependency

Store {username, salt, iterations, hash} as JSON via a new host_agent/local_account.py::LocalAccountStore, mirroring identity.py::HostIdentityStore's atomic temp-file-then-os.replace write and chmod 0600. Hash with hashlib.pbkdf2_hmac("sha256", password, salt, iterations=600_000) (OWASP 2023 minimum for PBKDF2-SHA256), random 16-byte salt via secrets.token_bytes, compare with hmac.compare_digest.

Alternatives considered: Argon2id (via argon2-cffi, as the still-unimplemented cloud-console-user-authentication proposal plans for cloud operator accounts) is the stronger choice for an internet-facing, multi-user login system under credential-stuffing risk. Here the credential only gates a one-time local CLI prompt on a machine the operator already has filesystem access to — it is not re-checked on an ongoing basis and is not reachable over the network. Adding a new native-extension dependency to device-host-agent for that threat model is disproportionate. PBKDF2 via stdlib hashlib needs no new dependency and is adequate here.

D2. First run is a real subcommand, not an implicit prompt-or-hang

cli.py::main() gains an explicit setup subcommand (device-host-agent setup) that interactively prompts (getpass.getpass) for username/password and writes the local account file, plus keeps the default (no subcommand) behavior as "run the daemon." When the default command runs and no local account file exists: if sys.stdin.isatty(), prompt inline (covers a manual first run in a terminal); if not a TTY (already running under systemd/launchd with no controlling terminal) and no account exists, fail fast with an explicit error telling the operator to run device-host-agent setup once, instead of hanging on a getpass call that can never be answered.

Alternatives considered: Always prompting inline on the default command is simpler but breaks headless service startup (a getpass call with no TTY either raises immediately with a confusing error or, depending on platform, blocks forever). A dedicated setup subcommand gives operators a clear, scriptable-once step, matching the existing precedent for interactive bootstrap (device-cloud-admin's planned getpass flow in cloud-console-user-authentication, D... — same shape, independent implementation per D1/Non-Goals).

D3. Cloud-side self-service enrollment is an additive, opt-in auth path

Add CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED (bool, default false) to control_config.py::CloudControlConfig, following the existing default-off pattern used by AI_PLANNER_ENABLED/SEMANTIC_ENRICHMENT_ENABLED elsewhere in this codebase. When enabled, apps/cloud-api/cloud_api/app.py composes the enrollment auth provider as a small ordered chain instead of the single ConfiguredEnrollmentTokenProvider:

  1. Try ConfiguredEnrollmentTokenProvider (existing behavior — a caller presenting a valid configured token still gets a token-bound principal, preserving edge-host-enrollment's conflict/idempotency semantics for that path unchanged).
  2. If that fails and self-service is enabled, fall back to a new SelfServiceEnrollmentAuthProvider that unconditionally returns an EnrollmentPrincipal(id="self-service", token_digest=None).
  3. Otherwise 401, exactly as today.

enroll_host()'s existing call pool.store.enroll_host(..., enrollment_token_digest=enrollment_principal.token_digest, ...) needs no change — passing None is already handled by the nullable column, and sql_repository.py's idempotent-retry-by-agent_instance_id path already runs before the token-conflict check, so retries from the same edge instance (same agent_instance_id) return the same host_id exactly as the token-based path does. The token-conflict lookup (WHERE enrollment_token_digest = <digest>) simply never matches NULL, so self-service requests never collide with each other on that column — every self-service call with a new agent_instance_id creates a new Host (expected; see Risks).

Alternatives considered: Making enroll_host() accept unauthenticated requests unconditionally (removing the auth check entirely) was rejected — it would silently change behavior for every existing deployment, including ones that never opt in. A configured, default-off flag keeps this strictly additive.

D4. Host Agent falls back to self-service automatically when no token is configured

enrollment.py::resolve_host_identity currently raises HostAgentConfigurationError when state.host_id is None and not config.enrollment_token. Change it to instead call client.enroll_host(...) with no bearer credential when enrollment_token is empty (the client already needs a code path for "no token" — see below), rather than raising. config.py::load_host_agent_config drops the corresponding startup validation branch (if not host_id and not enrollment_token and not identity_path.is_file(): raise ...), since a missing enrollment token is no longer a configuration error — it now means "attempt self-service enrollment."

If the target cloud has CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED=false (the default), this call fails with 401 from the cloud, surfaced as a runtime enrollment error at Host Agent startup — a clear, actionable failure (not a silent hang), and no worse than today's explicit config-time rejection.

Host Agent also passes the local account's username as display_name in the enrollment call when HOST_AGENT_DISPLAY_NAME is not explicitly set, so cloud-side operators can see who set up a self-enrolled Host without building any cross-system user linkage.

D5. HOST_AGENT_CONTROL_PLANE_URL default becomes https://amcp.home.jerryyan.top, override preserved

load_host_agent_config() changes only the default value passed to values.get("HOST_AGENT_CONTROL_PLANE_URL", "https://amcp.home.jerryyan.top"). The existing urlparse scheme/netloc validation, and the environment-variable override, are unchanged — this keeps local/dev/test runs (which set the env var to point at a local cloud-api instance) working exactly as before.

Risks / Trade-offs

  • [Risk] Unlimited anonymous Host registration — with self-service enabled, any caller reaching the control-plane URL can create arbitrarily many Host rows (no rate limit, no approval). → Mitigation: accepted for this single-operator deployment; primary control is keeping the flag off everywhere except amcp.home.jerryyan.top's own cloud-api environment, and relying on network-perimeter controls (reverse proxy/firewall) rather than application-level throttling, which this change deliberately does not add (see Non-Goals) to avoid a false sense of security from an easily-bypassed in-process limiter.
  • [Risk] Local account file loss or corruption blocks daemon startup on a TTY-less host — if HOST_AGENT_LOCAL_ACCOUNT_PATH is deleted or unreadable on a systemd-managed host, the daemon now fails fast instead of starting. → Mitigation: this is intentional (matches the explicit local-gate requirement); the error message names the exact device-host-agent setup remediation step.
  • [Risk] Self-service Hosts are indistinguishable from token-enrolled Hosts once createdenrollment_token_digest = NULL is the only marker of a self-service Host. → Mitigation: acceptable per existing edge-host-enrollment scoping (no admin UI in scope there either); an operator can still find/revoke via revoke_enrolled_host using host_id, and NULL vs non-NULL enrollment_token_digest is queryable if this needs auditing later.
  • [Trade-off] PBKDF2 instead of Argon2id (D1) — weaker under GPU/ASIC attack than Argon2id, acceptable only because this credential is never exposed to a network-facing verification endpoint in this change.

Migration Plan

  1. Ship the cloud-side change first (CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED defaulting to false) — no behavior change for any existing deployment.
  2. Explicitly set CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED=true in the amcp.home.jerryyan.top cloud-api deployment configuration only.
  3. Ship the Host Agent change (new default URL, local-account gate, self-service fallback). Existing installs with a populated HOST_AGENT_IDENTITY_PATH are unaffected — resolve_host_identity returns early when config.host_id and config.token or a cached host_id already exists, so already-enrolled Hosts never re-enroll.
  4. New installs: operator runs device-host-agent setup once, then starts the service normally; it self-enrolls against the fixed URL.

Rollback: setting CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED=false again immediately stops new self-service enrollments; Hosts already enrolled that way keep authenticating normally (auth is by credential digest, independent of how enrollment_token_digest was populated) and can be individually revoked via the existing revoke_enrolled_host path if needed. Reverting HOST_AGENT_CONTROL_PLANE_URL's default requires a Host Agent redeploy but does not affect already-enrolled identity state.

Open Questions

  • None blocking; the edge-host-enrollment change is implemented but not yet archived into openspec/specs/ — this change's delta spec is authored against its pending spec content and should be reconciled (or the two archived together) when edge-host-enrollment is archived.