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.
47 lines
6.3 KiB
Markdown
47 lines
6.3 KiB
Markdown
## 1. Host Agent local account storage
|
|
|
|
- [x] 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
|
|
- [x] 1.2 Implement PBKDF2-HMAC-SHA256 hashing (`hashlib.pbkdf2_hmac`, 600,000 iterations, `secrets.token_bytes(16)` salt) and `hmac.compare_digest`-based verification
|
|
- [x] 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`)
|
|
- [x] 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
|
|
|
|
- [x] 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
|
|
- [x] 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
|
|
- [x] 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
|
|
|
|
- [x] 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
|
|
- [x] 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
|
|
- [x] 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
|
|
- [x] 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
|
|
- [x] 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
|
|
|
|
- [x] 4.1 Add `CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED` (bool, default `false`) to `control_config.py::CloudControlConfig`/`load_control_config`
|
|
- [x] 4.2 Add `SelfServiceEnrollmentAuthProvider` to `auth.py`, returning a fixed `EnrollmentPrincipal(id="self-service", token_digest=None)` unconditionally
|
|
- [x] 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`
|
|
- [x] 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
|
|
|
|
- [x] 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`)
|
|
- [x] 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
|
|
|
|
- [x] 6.1 Update `.env.example` with `CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED` (documented default `false`) and the new default `HOST_AGENT_CONTROL_PLANE_URL` behavior
|
|
- [x] 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
|
|
- [x] 6.3 Update `docs/CLOUD_DEPLOYMENT.md` with the self-service enrollment flag, its security implications, and rollback steps
|
|
- [x] 6.4 Update `docs/MACOS_IPHONE_SETUP.md` with the new `device-host-agent setup` first-run step
|
|
|
|
## 7. Verification
|
|
|
|
- [x] 7.1 Run formatting, lint, and the full non-integration test suite across the workspace (`uv run --all-packages pytest -m "not integration"`) — 494 passed; no lint/formatter (ruff/mypy) is configured in this repo, so only the test suite ran
|
|
- [ ] 7.2 Run the PostgreSQL-backed repository tests for the new nullable-`enrollment_token_digest` self-service paths — not run: no `TEST_POSTGRES_URL`/Docker available in this environment; the SQLite side of the same parametrized tests (`tests/test_cloud_repository_contract.py`) passed
|
|
- [x] 7.3 Run `openspec validate edge-host-self-enrollment --strict` and resolve all artifact/spec errors — passed
|
|
- [ ] 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 — not performed: no real cloud-api deployment reachable from this environment; covered by automated integration-style tests instead (`test_enrollment.py`, `test_app.py`, `test_e2e.py`)
|