Files
agentic-mobile-control/openspec/changes/host-agent-console-jinja2-templates/design.md
T
q792602257andClaude Opus 4.6 8381b3068a
Tests / Test failed: 4, passed: 744
feat(host-agent): migrate local console to Jinja2 templates with autoescape
Replace hand-written f-string + html.escape() rendering in the Host Agent
local console with a module-level Jinja2 Environment configured with
select_autoescape(["html","xml"]). XSS safety now holds by mechanism
rather than per-call discipline — every operator-controlled field
(device name, connection_info, task summary, etc.) is escaped by the
engine uniformly.

Eight templates under host_agent/web/templates/ replace the former
_chrome(), _CSS, escape(), and per-page _xxx_body() helpers: base.html
(header/nav/CSS + {% block body %}), login, dashboard (with the polling
<script> preserved byte-identically inside {% raw %}), devices, account,
history, tasks_list, and task_detail. The task-list and task-detail
templates — added by the just-landed task-execution-progress-visibility
change — were also migrated here rather than left in f-string form,
since this change removes the shared helpers they depended on.

URLs, auth/session/CSRF semantics, redirects, and /api/status JSON are
unchanged. 15 new template tests cover render-smoke, XSS probing, script
byte-identity, and no-autoescape-bypass guards. Tasks 8.1-8.6 (manual
browser verification) remain.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-14 13:05:19 +08:00

18 KiB

Context

apps/device-host-agent/host_agent/web/app.py currently renders five HTML pages (login, status dashboard, devices, account, history) by composing f-string templates with a module-level escape() helper (a thin wrapper over html.escape(..., quote=True)). A shared _chrome(title, body_html, session) function acts as a hand-written base template; a _CSS string constant holds the inline stylesheet; the dashboard page embeds a <script> polling block whose JS braces are doubled ({{/}}) to survive f-string parsing.

This shape was an explicit decision in openspec/changes/host-agent-local-console/design.md, titled "Reuse FastAPI + Starlette's HTMLResponse, not a new micro-framework, not Jinja2", justified at the time by a "small page count" assumption (login, dashboard, devices, history) and the mitigation that "a single small escape()-wrapping helper used for every interpolated value" would keep the discipline manageable.

Two things have changed since that decision was made:

  1. The page count has grown (account was added) and is about to grow again: openspec/changes/task-execution-progress-visibility (in flight) plans to add three more Host-Agent-side pages (task list, task detail, timeline). The timeline page in particular is a loop-rendered table of externally-influenced text (task summaries, tool calls, scene/element descriptions).
  2. Every operator-controllable field (device.name, device.driver_type, connection_info, the upcoming task summaries) is one missed escape(...) call away from an XSS sink. The f-string approach offloads escaping discipline onto the author and reviewer of every change to web/app.py. This is acceptable when there are four pages written by one author in one PR; it stops being acceptable as the page set and author count grow.

device-host-agent's current direct dependencies are device-agent-runtime, device-cloud-platform, fastapi, httpx, uvicorn[standard]. None of these force jinja2 into the resolved uv.lock (starlette's Jinja2Templates is an optional extra that the Host Agent does not currently import), so adding Jinja2 is a genuine new direct dependency, not surfacing a transitively-resolved one.

Goals / Non-Goals

Goals:

  • Render every Host Agent console HTML page through a template engine whose autoescape is enabled by configuration, so the XSS-safety of any interpolated value no longer depends on the page author remembering to call escape().
  • Move the rendering shape closer to what task-execution-progress-visibility will need for its task/timeline pages (loops, conditionals, inheritance), so that change does not have to grow the f-string code further and then migrate later.
  • Preserve the current pages' observable contract byte-for-byte where it matters to a browser or operator: same URLs, same form fields, same redirects, same session/CSRF behavior, same 5-second /api/status polling, same visual look.
  • Keep the rendering stack boring and single-dependency: one template engine, no static-asset pipeline, no SPA tooling, no new framework.

Non-Goals:

  • No new pages, no new routes, no new visual design, no CSS refactor — pages are migrated mechanically.
  • No extraction of the inline <style> into a static .css file or pipeline (no starlette.staticfiles); the CSS block moves into base.html and stays inline. A future change can extract it if the stylesheet grows.
  • No extraction of the inline dashboard polling <script> into a .js file; the JS stays in the template, just no longer f-string-escaped.
  • No change to host_agent/web/auth.py (session store, CSRF, PBKDF2 verification).
  • No change to HostAgentConfig, the console lifecycle wiring in host_agent/app.py, or the loopback-vs-non-loopback bind semantics.
  • No change to the console/ Runtime SPA or the cloud-console/ SPA; both keep their Vue3 + Vite toolchains untouched.
  • No move to Starlette's Jinja2Templates class — the Host Agent already uses HTMLResponse(content=...) and only needs a configured jinja2.Environment plus template.render(...). Pulling in Jinja2Templates would couple rendering to a Starlette version-specific signature (TemplateResponse(request, name, context=...) changed across versions) for no functional gain. The Environment is called directly.

Decisions

D1: Jinja2 as the template engine

jinja2 (pure-Python wheel, no C extension, single dependency) is added as a direct dependency of device-host-agent. The rendering code constructs a jinja2.Environment configured with autoescape=select_autoescape(["html", "xml"]) and a FileSystemLoader pointing at a new host_agent/web/templates/ directory.

Alternatives considered:

  • Mako / Chameleon / Genshi: smaller ecosystems, less familiarity, more fragile under modern Python toolchains. Jinja2 is the de facto default for Python server-rendered HTML (Django, Flask, FastAPI docs all use or reference it); picking anything else trades familiarity for no concrete benefit at this scale.
  • Starlette's Jinja2Templates: see Non-Goals — rejected because the wrapper buys nothing the Host Agent needs (it is mainly useful when the same app mixes JSON and template responses behind the same routing conventions the Host Agent already has via HTMLResponse), and its signature has been unstable across Starlette versions. Calling Environment.get_template(...).render(...) directly keeps the rendering code version-agnostic and explicit.
  • stdlib string.Template + a custom autoescape wrapper: would reproduce Jinja2 with less ergonomic syntax and no {% for %}/{% if %}/{% extends %} — i.e. would re-introduce the f-string pain with weaker tooling. Rejected.
  • Stay with f-strings and add a bandit/semgrep rule to flag bare interpolations: lint-based mitigation does not change the mechanism; it adds a rule that has to be maintained and can be bypassed by any non-trivial builder pattern. Rejected in favor of removing the failure mode entirely.

D2: One module-level Environment, FileSystemLoader rooted at host_agent/web/templates/

Templates live in apps/device-host-agent/host_agent/web/templates/:

host_agent/web/templates/
  base.html         # <!doctype>, <head>, <style>, header/nav, {% block body %}{% endblock %}
  login.html        # {% extends "base.html" %}
  dashboard.html
  devices.html
  account.html
  history.html

A single module-level _ENV = jinja2.Environment(...) is created at import time in host_agent/web/app.py. Jinja2 compiles templates lazily on first get_template() call and caches the compiled bytecode on the Environment for the lifetime of the process, so subsequent renders are dict-lookup + context-bind — no recompilation. The Environment is thread-safe for read-only use after construction; the console's only writes are during Environment(...) construction itself, which happens once.

PackageLoader("host_agent.web", "templates") was considered as an alternative to FileSystemLoader. Rejected because PackageLoader relies on importlib.resources semantics that interact awkwardly with editable/uv sync installs during local development (template changes not picked up without reinstall); FileSystemLoader(__file__).parent / "templates" resolves correctly under both wheel install and editable workspace layout, and is the pattern Jinja2's own documentation recommends for application code.

D3: autoescape=select_autoescape(["html", "xml"]) rather than autoescape=True

select_autoescape(["html", "xml"]) enables autoescape for templates whose names end in .html or .xml and disables it for others. This is the recommended default in Jinja2's documentation. It provides:

  • Automatic HTML escaping for every {{ value }} interpolation in .html templates, so the spec's XSS-safety property holds without author discipline.
  • An opt-out path for the rare case where a template intentionally produces HTML markup from trusted Python-side builders (e.g. a pre-rendered fragment) — that case uses {% autoescape false %}...{% endautoescape %} or | safe filter. No current page needs this; the option is documented for future authors.

autoescape=True (always on) was considered and rejected because it would prevent any future .txt/.csv/.json-flavored template (none planned today, but the option is preserved at zero cost).

D4: base.html replaces _chrome() and _CSS

The current _chrome(title, body_html, session) function becomes base.html:

<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <title>{{ title }}</title>
  <style>{% block styles %}/* …current _CSS content… */{% endblock %}</style>
</head>
<body>
  <header>
    <strong>Host Agent Console</strong>
    {% block nav %}{% if session %}
      <nav>…same nav as today…</nav>
    {% endif %}{% endblock %}
  </header>
  <main>
    {% block body %}{% endblock %}
  </main>
</body>
</html>

Each page template starts with {% extends "base.html" %} and overrides {% block body %} (and optionally {% block nav %} or {% block styles %} for the rare page that needs to). The login page overrides {% block nav %} to empty (matching today's _login_page behavior where session=None).

The module-level _CSS, _chrome, and escape helpers in host_agent/web/app.py are removed in the same change; they are fully subsumed by base.html + autoescape.

D5: Dashboard inline <script> moves into the template under {% raw %}

The dashboard's 5-second /api/status polling script currently lives inside _dashboard_body as an f-string, with every JS { and } doubled to {{/}} to escape from f-string interpolation. In Jinja2 the same collision exists ({{ ... }} is Jinja2 expression syntax). The script moves verbatim into dashboard.html wrapped in {% raw %}...{% endraw %}, which instructs Jinja2 to pass the content through without parsing.

Alternative considered: extract the script into a separate .js file served via starlette.staticfiles. Rejected for this change because (a) it would require a static-asset mount on the FastAPI sub-app, which D1's Non-Goals explicitly defers, and (b) the script is small and tightly coupled to the dashboard's DOM. If the script grows, the extraction can happen in a later change.

A regression assertion in tasks.md verifies the script body is byte-identical to the pre-migration version (modulo whitespace).

D6: Route handlers call _ENV.get_template(name).render(context) and return HTMLResponse(content=...)

Each existing f-string handler becomes:

@app.get("/devices", response_class=HTMLResponse)
async def devices_page(request: Request, session: SessionState = Depends(require_session)):
    devices = await asyncio.to_thread(config_store.list)
    edit_id = request.query_params.get("edit")
    edit_record = await asyncio.to_thread(config_store.get, edit_id) if edit_id else None
    html = _ENV.get_template("devices.html").render(
        session=session,
        csrf_token=session.csrf_token,
        devices=devices,
        edit_record=edit_record,
        error=None,
    )
    return HTMLResponse(html)

The shape mirrors the current code as closely as possible — same asyncio.to_thread calls, same data fetched, same single HTMLResponse return — so the diff is dominated by "swap f-string for render(...)" and reviewing it stays mechanical.

TemplateResponse (Starlette's helper) is not used; see D1's Non-Goals discussion. HTMLResponse(content=html) is what the code calls today and keeps working.

D7: Forms and CSRF tokens keep using autoescape

CSRF tokens are random secrets.token_urlsafe() strings and contain no HTML metacharacters, so autoescape is a no-op on them semantically. But the spec requirement ("every interpolated field is escaped by mechanism") applies uniformly; CSRF tokens go through the same {{ csrf_token }} interpolation as everything else. This keeps the rule simple and means a future change to the token alphabet (e.g. including = padding) cannot silently introduce an escaping bug.

D8: No template rendering inside host_agent/web/auth.py

auth.py deals with session tokens, CSRF, and PBKDF2 verification. It returns booleans, tokens, and SessionState objects to app.py, never HTML. That boundary is preserved: app.py is the only module that touches the Environment. This keeps the autoescape contract easy to audit — anything that renders HTML is in one file, and every render call goes through the same configured Environment.

Risks / Trade-offs

  • [Risk] A new direct dependency (jinja2) widens device-host-agent's supply chain. → Mitigation: Jinja2 is pure-Python, single wheel, maintained by the Pallets organization (same as Flask/Sphinx/Werkzeug), no C extension, no transitive runtime dependencies beyond markupsafe (already transitively present via fastapi/starlette). uv.lock gains one direct package + zero new transitive runtime packages.
  • [Risk] First-render compile cost on each template (sub-millisecond). → Accepted: it is paid once per process lifetime, cached thereafter, and the console is a low-traffic single-operator page. No measurable impact on startup time or request latency.
  • [Risk] A subtle behavior change slips in during the mechanical migration (a escape() call forgotten, a | safe filter introduced, a conditional inverted). → Mitigation: tasks.md requires a regression test that takes the pre-migration HTML of each page (captured via the existing test snapshot or by running the current code) and asserts the post-migration HTML is structurally equivalent; an XSS probe test injects <script>alert(1)</script> into every operator-controlled field and asserts the escaped form appears in the output.
  • [Trade-off] The dashboard's <script> is now inside a {% raw %} block rather than extracted into a static asset.] → Accepted: keeps the change scope tight (no static-asset mount) and the script co-located with the DOM it manipulates. Trade-off recorded for future revisit if the script grows.
  • [Trade-off] The original host-agent-local-console design's explicit decision against Jinja2 is hereby superseded for the rendering-mechanism question only.] → Mitigation: recorded as an Open Question to be reconciled when host-agent-local-console is archived. Until then, this change's spec captures the autoescape requirement as a new capability (host-agent-console-template-rendering) rather than a MODIFIED delta against an unarchived pending spec, matching the precedent set by task-execution-progress-visibility and cloud-planner-proxy when they needed to reference host-agent-local-console / ai-planner-runtime before those were archived.

Migration Plan

  1. Add jinja2>=3.1 to apps/device-host-agent/pyproject.toml's dependencies. Run uv lock --check after uv lock to confirm the lockfile is in sync. Confirm markupsafe is the only new transitive package.
  2. Create host_agent/web/templates/ with base.html and one file per current page. Capture the byte-level structure of each current page (login, dashboard including the inline script, devices, account, history) before migration as a reference for the regression assertion.
  3. In host_agent/web/app.py, construct the module-level _ENV and rewrite each route handler per D6. Remove _chrome, _CSS, _login_page, _dashboard_body, _devices_body, _account_body, _history_body, and the escape helper.
  4. Add apps/device-host-agent/tests/host_agent/web/test_templates.py covering: (a) each template renders without raising for representative contexts; (b) the XSS probe appears HTML-escaped in every rendered page that interpolates an operator-controlled field; (c) the dashboard <script> body is byte-identical to the pre-migration reference.
  5. Run the existing Host Agent test suite (uv run --package device-host-agent pytest) and the root non-integration suite; confirm no regressions.
  6. Run ruff check --fix, ruff format, and python -m compileall over the changed files.
  7. Run openspec validate host-agent-console-jinja2-templates --strict.
  8. Manual browser verification: log in, view status dashboard auto-refresh, add/edit/remove a device, change password, view history, log out. Confirm no visual or behavior change versus the pre-migration pages.
  9. Rollback: revert the commit. The change introduces no on-disk state format change (templates are code, not data) and no schema migration. The only artifact a previous run may leave is the same SQLite history file the unchanged code would have written; it is unaffected.

Open Questions

  • Coordination with task-execution-progress-visibility. That change's design.md D3 currently commits to "reuse api/console.py's query patterns and f-string + html.escape() convention" for its new task list/detail/timeline pages. After this change lands, those pages SHOULD be written directly against the Jinja2 templates introduced here (extending base.html, looping over timeline entries with {% for %}). The two changes do not conflict at the code level (different files within host_agent/web/), but the D3 wording in task-execution-progress-visibility/design.md will need a follow-up edit. Options: (a) edit D3 in task-execution-progress-visibility once this change is on master; (b) defer the wording update until that change is next revised. Either is acceptable; the decision does not block this change.
  • Supersession of host-agent-local-console's "not Jinja2" decision. When host-agent-local-console is archived into openspec/specs/host-agent-local-console/spec.md, this change's host-agent-console-template-rendering capability becomes a sibling rather than a delta. The reconciler should decide at archive time whether to fold the autoescape requirement into host-agent-local-console's spec directly (and retire host-agent-console-template-rendering as a standalone capability) or keep it as a sibling. Recorded for the archive step; not a blocker for this change.
  • Whether to introduce Starlette's Jinja2Templates for consistency with the broader FastAPI ecosystem. Currently rejected (D1, Non-Goals). Revisit if a future change adds request-scoped rendering concerns (flash messages, per-request template overrides) that Jinja2Templates would simplify.