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

13 KiB

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.

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.