This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-07-13
|
||||
@@ -0,0 +1,74 @@
|
||||
## 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.
|
||||
@@ -0,0 +1,28 @@
|
||||
## Why
|
||||
|
||||
The Host Agent today cannot start unattended on a fresh install: it requires an operator to hand-carry an out-of-band `HOST_AGENT_ENROLLMENT_TOKEN` (issued via `CLOUD_ENROLLMENT_TOKENS_JSON` on the cloud side) before it will enroll, and it accepts any `HOST_AGENT_CONTROL_PLANE_URL` an installer happens to set, which for the single home deployment at `https://amcp.home.jerryyan.top` is unnecessary ceremony. There is also no local gate preventing the daemon from starting with no operator ever having touched the machine, and no local record of who set it up. This change removes the pre-issued-token requirement for this deployment, fixes the control-plane address, and adds a one-time local setup gate, while leaving the existing token-based and static-credential paths intact for other deployments.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add a Host Agent CLI first-run bootstrap: if no local account file exists, `device-host-agent` interactively prompts (via `getpass`) for a username and password, hashes the password, and persists the credential atomically with restricted file permissions before continuing. On later starts, if the account file exists, the daemon starts straight into background polling with no prompt.
|
||||
- Change `host_agent/config.py::load_host_agent_config` default for `HOST_AGENT_CONTROL_PLANE_URL` from `http://127.0.0.1:8001` to `https://amcp.home.jerryyan.top`; the environment variable still overrides it for development/testing.
|
||||
- **BREAKING** (new deployments only, additive for existing ones): Remove the hard requirement for `HOST_AGENT_ENROLLMENT_TOKEN` when no local identity and no static `HOST_AGENT_HOST_ID`/`HOST_AGENT_TOKEN` are configured. `host_agent/enrollment.py::resolve_host_identity` now falls back to an unauthenticated self-service enrollment call when no enrollment token is configured, instead of raising `HostAgentConfigurationError`.
|
||||
- Add a cloud-side self-service enrollment mode, gated by a new `CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED` flag (default `false`): when enabled, `POST /internal/v1/enrollments` accepts requests with no enrollment-token bearer credential and stores `enrollment_token_digest = NULL` for that Host row (the column is already nullable). When the flag is disabled, current behavior (token required, `401` otherwise) is unchanged.
|
||||
- Existing static-credential (`HOST_AGENT_HOST_ID`/`HOST_AGENT_TOKEN`), token-based enrollment (`HOST_AGENT_ENROLLMENT_TOKEN` + `CLOUD_ENROLLMENT_TOKENS_JSON`), and revocation paths from `edge-host-enrollment` are unchanged and remain fully supported side by side with self-service enrollment.
|
||||
- No changes to `host_agent/heartbeat.py` — periodic device-status reporting already exists and continues to run against the (now-fixed) control-plane URL; this change only confirms the wiring stays intact once the URL and enrollment path change.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `host-agent-local-bootstrap`: First-run interactive local account creation gate for the Host Agent CLI — credential storage format, hashing, one-time prompt behavior, and non-interactive skip on subsequent starts.
|
||||
|
||||
### Modified Capabilities
|
||||
- `edge-host-enrollment`: Add a cloud-side self-service enrollment mode that does not require a pre-issued enrollment token, and change the Host Agent's default control-plane URL and fallback behavior when no enrollment token is configured. (Note: this capability's spec currently lives only in the not-yet-archived `openspec/changes/edge-host-enrollment/` change, not in canonical `openspec/specs/`; this change's delta is authored against that pending spec and should be reconciled when `edge-host-enrollment` is archived.)
|
||||
|
||||
## Impact
|
||||
|
||||
- **Host Agent (`apps/device-host-agent`)**: new `host_agent/local_account.py` (or similarly named) module and CLI wiring in `host_agent/cli.py`; `host_agent/config.py` default URL change; `host_agent/enrollment.py::resolve_host_identity` fallback behavior change; new local credential file (default path under the existing `tasks/` state directory, permissions `0600`).
|
||||
- **Cloud Platform (`packages/cloud-platform/cloud`, `apps/cloud-api`)**: `control_config.py` gains `CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED`; `internal_api/api.py::enroll_host` gains a self-service path; no schema migration required (`enrollment_token_digest` is already nullable).
|
||||
- **Dependencies**: no new third-party dependency for password hashing is planned (stdlib-based); to be confirmed in design.md.
|
||||
- **Deployment/docs**: `.env.example`, `compose.yaml`/`compose.deploy.yaml`, and `docs/CLOUD_DEPLOYMENT.md` need the new flag documented; `docs/MACOS_IPHONE_SETUP.md` needs the first-run local-account step documented.
|
||||
- **Security posture**: enabling `CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED` means any network caller reaching the control-plane URL can self-register as a Host with no approval step; this is an explicit, accepted tradeoff for the single-operator home deployment and is opt-in (default off) for other deployments.
|
||||
@@ -0,0 +1,46 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Cloud supports opt-in self-service Host enrollment without a pre-issued token
|
||||
The Cloud Control Plane SHALL support a configuration flag that, when enabled, allows the Host enrollment operation to succeed for a caller presenting no valid enrollment-token credential, generating a `host_id` and persisting a Host row with no enrollment-token binding. When the flag is disabled (the default), enrollment behavior SHALL be unchanged from the existing token-required path.
|
||||
|
||||
#### Scenario: Self-service enrollment is enabled and no token is presented
|
||||
- **WHEN** self-service enrollment is enabled and an edge instance presents a new instance identifier and a high-entropy candidate Host credential with no enrollment-token bearer credential
|
||||
- **THEN** the control plane returns a generated `host_id` and durably stores the Host credential digest with no enrollment-token binding
|
||||
|
||||
#### Scenario: Self-service enrollment is disabled
|
||||
- **WHEN** self-service enrollment is disabled and an edge instance presents no enrollment-token bearer credential
|
||||
- **THEN** the control plane rejects the request exactly as it does today, without creating a Host identity
|
||||
|
||||
#### Scenario: A valid configured enrollment token is still presented while self-service is enabled
|
||||
- **WHEN** self-service enrollment is enabled and an edge instance presents a valid unused configured enrollment token
|
||||
- **THEN** the control plane enrolls the Host using the existing token-bound path, including its idempotency and conflict semantics
|
||||
|
||||
#### Scenario: Self-service enrollment retried by the same instance
|
||||
- **WHEN** an edge instance that previously completed self-service enrollment repeats enrollment with the same instance identifier and Host credential digest
|
||||
- **THEN** the control plane returns the original `host_id` without creating a duplicate Host
|
||||
|
||||
### Requirement: Host Agent falls back to self-service enrollment when no enrollment token is configured
|
||||
The Host Agent SHALL attempt Host enrollment without an enrollment-token bearer credential when no `HOST_AGENT_ENROLLMENT_TOKEN` is configured and no cached Host identity exists, rather than treating the missing token as a startup configuration error.
|
||||
|
||||
#### Scenario: Fresh install with no enrollment token configured
|
||||
- **WHEN** the Host Agent starts with no static Host credentials, no enrollment token, and no cached identity state
|
||||
- **THEN** it generates a local instance identifier and candidate Host credential and submits an enrollment request with no enrollment-token bearer credential
|
||||
|
||||
#### Scenario: Self-service enrollment is rejected by the cloud
|
||||
- **WHEN** the Host Agent submits a self-service enrollment request and the cloud rejects it because self-service enrollment is disabled there
|
||||
- **THEN** the Host Agent surfaces a clear enrollment failure at startup and does not start background polling
|
||||
|
||||
#### Scenario: A configured enrollment token is still honored
|
||||
- **WHEN** the Host Agent starts with `HOST_AGENT_ENROLLMENT_TOKEN` configured
|
||||
- **THEN** it uses the existing token-bound enrollment path unchanged
|
||||
|
||||
### Requirement: Host Agent control-plane URL defaults to the managed cloud platform address
|
||||
The Host Agent SHALL default `HOST_AGENT_CONTROL_PLANE_URL` to `https://amcp.home.jerryyan.top` when the environment variable is not explicitly set, while continuing to allow the environment variable to override it.
|
||||
|
||||
#### Scenario: No control-plane URL is configured
|
||||
- **WHEN** the Host Agent starts with `HOST_AGENT_CONTROL_PLANE_URL` unset
|
||||
- **THEN** it connects to `https://amcp.home.jerryyan.top`
|
||||
|
||||
#### Scenario: Control-plane URL is explicitly configured
|
||||
- **WHEN** the Host Agent starts with `HOST_AGENT_CONTROL_PLANE_URL` set to a different HTTP(S) URL
|
||||
- **THEN** it connects to the explicitly configured URL instead of the default
|
||||
@@ -0,0 +1,31 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Host Agent requires a local account before unattended operation
|
||||
The Host Agent SHALL refuse to start its background polling loop until a local account (username and hashed password) exists on disk, and SHALL provide an explicit interactive command to create that account.
|
||||
|
||||
#### Scenario: No local account exists and setup is run interactively
|
||||
- **WHEN** an operator runs the Host Agent's setup command on a machine with no local account file
|
||||
- **THEN** the process prompts for a username and password, persists a hashed credential, and does not echo the password back
|
||||
|
||||
#### Scenario: Local account already exists
|
||||
- **WHEN** the Host Agent's default (run) command starts and a valid local account file is already present
|
||||
- **THEN** the process starts background polling immediately without prompting for any credential
|
||||
|
||||
#### Scenario: No local account exists and the process has no interactive terminal
|
||||
- **WHEN** the Host Agent's default (run) command starts with no local account file and no controlling terminal available
|
||||
- **THEN** the process exits with an error identifying the setup command to run, without hanging or prompting
|
||||
|
||||
### Requirement: Local account credentials are stored hashed and access-restricted
|
||||
The Host Agent SHALL persist the local account password only as a salted hash, never in plaintext, and SHALL restrict the credential file's filesystem permissions to the owning user.
|
||||
|
||||
#### Scenario: Local account file is written
|
||||
- **WHEN** the setup command creates a new local account
|
||||
- **THEN** the persisted file contains the username, a random per-account salt, an iteration count, and a derived password hash, and contains no plaintext password
|
||||
|
||||
#### Scenario: Local account file permissions
|
||||
- **WHEN** the Host Agent writes or rewrites the local account file
|
||||
- **THEN** the file is only readable and writable by the owning user
|
||||
|
||||
#### Scenario: Local account file is corrupted or unreadable
|
||||
- **WHEN** the Host Agent attempts to load a local account file that is not valid, readable JSON in the expected shape
|
||||
- **THEN** the process reports a clear error and does not start background polling
|
||||
@@ -0,0 +1,46 @@
|
||||
## 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
|
||||
- [ ] 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`)
|
||||
- [ ] 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.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
|
||||
- [ ] 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.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
|
||||
- [ ] 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
|
||||
- [ ] 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.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
|
||||
- [ ] 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)
|
||||
|
||||
## 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`)
|
||||
- [ ] 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.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
|
||||
- [ ] 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
|
||||
|
||||
## 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"`)
|
||||
- [ ] 7.2 Run the PostgreSQL-backed repository tests for the new nullable-`enrollment_token_digest` self-service paths
|
||||
- [ ] 7.3 Run `openspec validate edge-host-self-enrollment --strict` and resolve all artifact/spec errors
|
||||
- [ ] 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
|
||||
Reference in New Issue
Block a user