Files
2026-07-14 07:31:31 +08:00

12 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.
  • Start the Console with every Host Agent while binding it to loopback by default; an operator must explicitly opt 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 runs a third concurrent task (alongside heartbeat_task and the claim/process loop) for a uvicorn.Server configured with install_signal_handlers=False. It is started and stopped using the same stop_requested/finally shutdown sequence as the heartbeat task, so Ctrl+C/service-stop cleanly tears down the Console too.

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.

Mandatory startup with loopback defaults

HostAgentConfig gains: 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 — with matching HOST_AGENT_CONSOLE_* environment variables except for an enable flag, because the Console is mandatory. 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 loopback defaults above; every Host Agent starts the Console.
  3. Implement the console module and wire its startup/shutdown into HostAgentApplication.run_async for every Host Agent process.
  4. Add the new bounded history store and the optional recorder hooks to AssignmentProcessor/HeartbeatSynchronizer, no-op by default.
  5. Document mandatory Console startup, loopback-only defaults, and the SSH port-forward recommendation for remote access in docs/CLOUD_DEPLOYMENT.md and docs/MACOS_IPHONE_SETUP.md.
  6. Rollback: deploy a prior Host Agent release if the embedded Console must be removed. 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.