Files
agentic-mobile-control/openspec/changes/host-agent-local-console/design.md
T
2026-07-13 19:45:53 +08:00

72 lines
13 KiB
Markdown

## Context
`apps/device-host-agent` is currently a headless asyncio process (`HostAgentApplication.run_async` in `host_agent/app.py`): a heartbeat task and a claim/execute/report loop, both driven purely by outbound HTTP calls to the Cloud API. It has no inbound listener of any kind, and its `pyproject.toml` declares only `httpx`, `device-agent-runtime`, and `device-cloud-platform` as direct dependencies — no web framework.
The root `device-agent-runtime` package (a workspace dependency of `device-host-agent`) already depends on `fastapi>=0.115.0` and `uvicorn[standard]>=0.30.0` (used by `api/rest.py` for the local single-machine Runtime API and by `console/`'s backend). Those packages are therefore already present in the resolved `uv.lock` and importable from `device-host-agent` today, even though `device-host-agent` does not declare them directly.
Local operator-facing state currently lives in three separate stores on the edge machine: `host_agent/local_account.py::LocalAccountStore` (PBKDF2 credential), `host_agent/identity.py::HostIdentityStore` (enrollment identity/host_id), and `storage/device_config.py::DeviceConfigStore` (locally registered devices, SQLite). None of it is visible or editable except by reading/editing these files directly or via `device-host-agent setup` (account creation only). `openspec/changes/edge-host-self-enrollment` (not yet implemented) defines the CLI-only first-account-creation flow; this change does not alter that requirement.
## Goals / Non-Goals
**Goals:**
- Give an operator on the edge machine a same-host web page to see heartbeat/enrollment/device/assignment status at a glance, and to perform the small set of actions that currently require hand-editing files: add/edit/remove a local device, change the local account password, review recent assignment/heartbeat outcomes.
- Keep the default deployment posture unchanged: console off by default; when enabled, bound to loopback only unless the operator explicitly opts into a wider bind address.
- Reuse the existing local account as the only credential — no second user/credential system.
- Server-rendered HTML, not a SPA: no new frontend build tooling, no JS framework, minimal inline `fetch()` calls only for the few sections that benefit from polling refresh (heartbeat status, current assignment progress).
**Non-Goals:**
- No TLS termination, reverse proxy, or certificate management built into the Host Agent — LAN exposure beyond loopback is an explicit, documented operator opt-in with the risk called out, not a feature this change makes safe by default.
- No multi-user accounts, roles, or audit logging beyond the bounded local history — the existing `local_account.py` model is single-account, and this change keeps it that way.
- No syncing of the new local assignment/heartbeat history to the Cloud Console; it is a purely local, best-effort operational aid, not a system of record (the Cloud API/`cloud-console` already own durable task/attempt history).
- No change to the outbound `host-agent-protocol` capability or to how devices are enrolled with the cloud; the console only calls the same local `DeviceConfigStore`/enrollment client code paths that `host_agent/app.py::_configured_device_manager` already uses at startup.
## Decisions
### Reuse FastAPI + Starlette's `HTMLResponse`, not a new micro-framework, not Jinja2
FastAPI/Starlette are already transitively resolved via `device-agent-runtime`. Adding them as **explicit** direct dependencies of `device-host-agent` (rather than relying on the transitive edge) is the only `pyproject.toml` change needed — no new third-party web framework enters the dependency graph. Pages are built with small Python functions returning `HTMLResponse(content=...)` from hand-written f-string templates with `html.escape()` on every interpolated value (no Jinja2: the page count is small — login, dashboard, devices, history — and a templating engine is unjustified surface area for a handful of server-rendered fragments). This matches the user's explicit direction: server-rendered pages, not the `console/`/`cloud-console/` SPA pattern, and not a new heavyweight dependency.
Alternative considered: `http.server`/stdlib-only implementation. Rejected — would duplicate routing, form parsing, and cookie handling that FastAPI/Starlette already provide for free given they're already in the dependency graph.
### Run the console server in the same asyncio loop as the heartbeat/claim loop
`HostAgentApplication.run_async` gains a third concurrent task (alongside `heartbeat_task` and the claim/process loop) that runs a `uvicorn.Server` configured with `install_signal_handlers=False` when `config.console_enabled`. It is started and stopped using the same `stop_requested`/`finally` shutdown sequence already used for the heartbeat task, so `Ctrl+C`/service-stop behavior is unchanged when the console is off (the default) and cleanly tears down the extra task when it's on.
Alternative considered: separate process/thread running its own event loop. Rejected — the console needs live references to the same `DeviceManager`, `HostAgentClient`, and in-flight assignment state that the main loop owns; a separate process would need its own IPC layer to read that state, which is unjustified complexity for a same-host admin page.
### Cookie session issued at login, not per-request Basic Auth
Login is a normal HTML form POST to `/login` that calls `LocalAccountStore.verify()` once (PBKDF2, 600 000 iterations — intentionally expensive, on the order of ~100ms+, which is fine for one login but would be a real cost if paid on every polled `fetch()`). On success the server issues a random opaque session token (`secrets.token_urlsafe`), stored in an in-memory `dict[str, SessionState]` (process-local; a restart invalidates all sessions, which is acceptable for a single-operator local admin page), and sets it as an `HttpOnly`, `SameSite=Strict`, `Secure`-when-not-loopback session cookie with a sliding expiry (e.g. 12h idle timeout). All other routes require a valid session and redirect to `/login` otherwise. This mirrors the cookie+session shape already validated in the sibling `cloud-console-user-authentication` change, applied here to a single local account instead of a multi-user table.
### CSRF token bound to the session, required on all mutating requests
Because authentication is a cookie the browser attaches automatically, every state-changing endpoint (device add/edit/remove, password change, logout) requires a per-session CSRF token — rendered into the page/forms and also required as a request header on the small number of `fetch()`-based mutations — checked against the value stored alongside the session. Read-only status/history polling endpoints do not require it.
### Local device CRUD updates the live `DeviceManager` in the same request, not just `DeviceConfigStore`
`device/manager.py::DeviceManager` already exposes `register_device`/`unregister_device`. The console's device-CRUD handlers call the same sequence `host_agent/app.py::_configured_device_manager` uses at startup (persist to `DeviceConfigStore`, call `enrollment_client.enroll_device(...)` when `config.enrollment_managed`, then `manager.register_device(...)`) so a device added or removed through the web page takes effect immediately, without requiring a Host Agent restart. This existing sequence is extracted into a small shared helper used by both the startup path and the new console routes, rather than duplicated.
### New bounded local history store for recent assignments/heartbeats
The Host Agent currently discards assignment outcomes once reported to the control plane and keeps no heartbeat history at all. A new local-only SQLite table (e.g. `tasks/host_console_history.sqlite3`, following the existing `storage.device_config` pattern of a small dedicated SQLite file under `tasks/`) records the last N (configurable, default e.g. 200) assignment results and heartbeat syncs. `AssignmentProcessor` and `HeartbeatSynchronizer` accept an optional recorder callback (no-op when the console is disabled, so there is zero overhead in the default configuration) that appends a row after each terminal report / heartbeat sync; the console's history page reads from this table. Retention is enforced by pruning beyond the configured cap on write, not by a separate cron/background task.
### Config additions, all opt-in and backward compatible
`HostAgentConfig` gains: `console_enabled: bool = False`, `console_bind_host: str = "127.0.0.1"`, `console_port: int = 8765`, `console_allow_non_loopback: bool = False`, `console_session_ttl_seconds: float = 43200.0` (12h), `console_history_limit: int = 200` — all with matching `HOST_AGENT_CONSOLE_*` environment variables following the existing `_positive_float`/`_positive_int` validation helpers in `config.py`. Loading raises `HostAgentConfigurationError` if `console_bind_host` resolves to a non-loopback address while `console_allow_non_loopback` is not set, so the risky configuration requires two explicit affirmative settings, not one.
## Risks / Trade-offs
- **[Risk] No TLS by default; a non-loopback bind sends the session cookie and form-posted password over cleartext HTTP on the LAN.** → Mitigation: loopback-only by default; non-loopback requires the explicit second opt-in flag; document the recommended alternative (SSH local port-forward to keep the console loopback-only while still reachable remotely) in `docs/CLOUD_DEPLOYMENT.md`/`docs/MACOS_IPHONE_SETUP.md` rather than building TLS support into this change.
- **[Risk] In-memory session store means every Host Agent process restart forces re-login.** → Accepted: restarts are infrequent for a background service and re-login is a low-friction PBKDF2 verify; avoids adding a persistent session store and its own cleanup/expiry code for a single-operator page.
- **[Risk] Blocking SQLite calls (`DeviceConfigStore`, new history store) on the same asyncio loop that runs heartbeat/claim could add latency under concurrent console use.** → Mitigation: wrap console route handlers' store calls in `asyncio.to_thread`, consistent with the existing `asyncio.to_thread(self.executor.execute, ...)` pattern in `host_agent/lease.py`; SQLite operations here are small and infrequent (one operator, occasional page loads) so this is a low-severity concern even without the wrapping, but the pattern costs nothing to apply consistently.
- **[Trade-off] Hand-written HTML via f-strings instead of a templating engine is more verbose per-page and pushes escaping discipline onto the author.** → Mitigation: a single small `escape()`-wrapping helper used for every interpolated value, and a lint/review checklist item (covered in tasks.md) rather than relying on an engine's autoescaping; the page count is small enough that this remains manageable.
- **[Trade-off] New local-only history duplicates, in miniature, information the Cloud Console already owns durably.** → Accepted: this history exists specifically for operators without (or before) Cloud Console access, or debugging when the control plane itself is unreachable; it is explicitly not a system of record (Non-Goals).
## Migration Plan
1. Add `fastapi`/`uvicorn[standard]` as explicit direct dependencies in `apps/device-host-agent/pyproject.toml` (versions already pinned in the shared `uv.lock` via the transitive edge — no version drift expected).
2. Add the new `HostAgentConfig` fields with the safe defaults above; existing deployments that don't set any `HOST_AGENT_CONSOLE_*` variable see no behavior change.
3. Implement the console module and wire its optional startup/shutdown into `HostAgentApplication.run_async`, gated on `config.console_enabled`.
4. Add the new bounded history store and the optional recorder hooks to `AssignmentProcessor`/`HeartbeatSynchronizer`, no-op by default.
5. Document how to enable the console (env vars, loopback-only default, SSH port-forward recommendation for remote access) in `docs/CLOUD_DEPLOYMENT.md` and `docs/MACOS_IPHONE_SETUP.md`.
6. Rollback: unset/leave `HOST_AGENT_CONSOLE_ENABLED` at its default `false`. No schema or state migration is introduced for existing stores (`DeviceConfigStore`, `LocalAccountStore`, `HostIdentityStore` are all read via their existing APIs, unchanged); the new history SQLite file is purely additive and can be deleted with no effect on Host Agent operation.
## Open Questions
- Exact default/max value for `console_history_limit` (row cap) — proposed default 200, may need tuning once real usage is observed.
- Whether a future change should let the Cloud Console optionally pull this local history for remote debugging (explicitly out of scope here; would need a new outbound protocol surface and its own review).
- Whether single-shared-account is sufficient long-term for edge machines with multiple physical operators, or whether that should be revisited alongside any future change to `local_account.py`'s single-account model.