12 KiB
Context
Two systems are affected:
- Host Agent (
apps/device-host-agent/host_agent/):cli.py::main()currently just callscreate_application().run().config.py::load_host_agent_config()defaultsHOST_AGENT_CONTROL_PLANE_URLtohttp://127.0.0.1:8001and raisesHostAgentConfigurationErrorunless one of (staticHOST_AGENT_HOST_ID+HOST_AGENT_TOKEN,HOST_AGENT_ENROLLMENT_TOKEN, or an existing identity file atHOST_AGENT_IDENTITY_PATH) is present.enrollment.py::resolve_host_identityraises ifHOST_AGENT_ENROLLMENT_TOKENis empty and no cachedhost_idexists.identity.py::HostIdentityStorealready establishes the atomic-write +chmod 0600pattern 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 viaenrollment_auth.authenticate(request)(ConfiguredEnrollmentTokenProvider, built fromCLOUD_ENROLLMENT_TOKENS_JSON), returning401if no configured token digest matches the bearer header.sql_repository.py::enroll_host()first checks for an existingHostRowbyagent_instance_id(idempotent retry, independent of the token) before checking whether the presentedenrollment_token_digestis already bound to another host. Bothcredential_digestandenrollment_token_digestcolumns onHostRow(db_models.py) are already nullable — no migration is needed to store aNULLenrollment_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-enrollmentbehavior 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-authenticationproposal'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 fromedge-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:
- Try
ConfiguredEnrollmentTokenProvider(existing behavior — a caller presenting a valid configured token still gets a token-bound principal, preservingedge-host-enrollment's conflict/idempotency semantics for that path unchanged). - If that fails and self-service is enabled, fall back to a new
SelfServiceEnrollmentAuthProviderthat unconditionally returns anEnrollmentPrincipal(id="self-service", token_digest=None). - 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_PATHis 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 exactdevice-host-agent setupremediation step. - [Risk] Self-service Hosts are indistinguishable from token-enrolled Hosts once created —
enrollment_token_digest = NULLis the only marker of a self-service Host. → Mitigation: acceptable per existingedge-host-enrollmentscoping (no admin UI in scope there either); an operator can still find/revoke viarevoke_enrolled_hostusinghost_id, andNULLvs non-NULLenrollment_token_digestis 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
- Ship the cloud-side change first (
CLOUD_SELF_SERVICE_ENROLLMENT_ENABLEDdefaulting tofalse) — no behavior change for any existing deployment. - Explicitly set
CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED=truein theamcp.home.jerryyan.topcloud-api deployment configuration only. - Ship the Host Agent change (new default URL, local-account gate, self-service fallback). Existing installs with a populated
HOST_AGENT_IDENTITY_PATHare unaffected —resolve_host_identityreturns early whenconfig.host_id and config.tokenor a cachedhost_idalready exists, so already-enrolled Hosts never re-enroll. - New installs: operator runs
device-host-agent setuponce, 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-enrollmentchange is implemented but not yet archived intoopenspec/specs/— this change's delta spec is authored against its pending spec content and should be reconciled (or the two archived together) whenedge-host-enrollmentis archived.