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

75 lines
12 KiB
Markdown

## 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 created** — `enrollment_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.