Tests / Test failed: 4, passed: 744
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>
10 KiB
10 KiB
1. Dependency and lockfile
- 1.1 Add
jinja2>=3.1to thedependencieslist inapps/device-host-agent/pyproject.toml - 1.2 Run
uv lock(oruv lock --package device-host-agent) and confirmjinja2plus its sole transitive runtime dependencymarkupsaferesolve; commit the updateduv.lock - 1.3 Run
uv sync --locked --all-packagesand confirm no package fails to install
2. Capture pre-migration reference
- 2.1 From a clean working copy of the current
host_agent/web/app.py, render each of the five pages (login, dashboard, devices, account, history) in-process with a representative context (mockSessionState, mockHostIdentityState, two sample devices, one history entry) and save the HTML toapps/device-host-agent/tests/host_agent/web/__baseline__/as<page>.html(one file per page). The dashboard reference must include the rendered inline<script>block. These files become the regression oracle for tasks 6.3 and 6.4 - 2.2 Note the exact bytes of the dashboard inline
<script>block (fromfunction render(data) {through the closing})();) into a separatedashboard_script.txtreference under the same baseline directory, for the byte-identical assertion in 6.4
3. Templates
- 3.1 Create
apps/device-host-agent/host_agent/web/templates/base.htmlper design D4:<!doctype html>,<head>with<meta charset="utf-8">,<title>{{ title }}</title>, a<style>block holding the current_CSScontent, a<header>containing<strong>Host Agent Console</strong>and a{% block nav %}that renders the same nav (Status / Devices / Account / History / Logout form) whensessionis truthy and empty otherwise, and a<main>containing{% block body %}{% endblock %} - 3.2 Create
templates/login.htmlextendingbase.html, overriding{% block nav %}to empty and{% block body %}with the current_login_pagebody markup, including the "no local account exists" branch (use{% if not account %}) and the optional error paragraph ({% if error %}) - 3.3 Create
templates/dashboard.htmlextendingbase.html, porting the current_dashboard_bodybody markup (enrollment/heartbeat/policy/current-assignment sections, devices table with a{% for device in devices %}loop) and the inline<script>block wrapped in{% raw %}...{% endraw %}so JS braces are not interpreted by Jinja2; the script body must be byte-identical to the reference captured in 2.2 - 3.4 Create
templates/devices.htmlextendingbase.html, porting the current_devices_bodymarkup including the device list table (loop with{% for device in devices %}), the optional error paragraph, and the add/edit form (use{% if edit_record %}to switch the form heading and pre-fill values) - 3.5 Create
templates/account.htmlextendingbase.html, porting the current_account_bodymarkup including the optional message/error paragraphs and the change-password form - 3.6 Create
templates/history.htmlextendingbase.html, porting the current_history_bodymarkup including the history table with a{% for entry in entries %}loop
4. Engine and route handlers
- 4.1 In
host_agent/web/app.py, construct a module-level_ENV = jinja2.Environment(loader=jinja2.FileSystemLoader(Path(__file__).parent / "templates"), autoescape=jinja2.select_autoescape(["html", "xml"])); importjinja2andpathlib.Pathat module top - 4.2 Rewrite the
/loginGET handler to renderlogin.htmlvia_ENV.get_template("login.html").render(account=account)and returnHTMLResponse(...); keep the currentasyncio.to_thread(local_account_store.load)call shape - 4.3 Rewrite the
/dashboard GET handler to renderdashboard.htmlwithidentity,snapshot,devices,config,sessionin the context; ensure the inline<script>survives{% raw %}migration byte-identically (visually diff against the 2.2 reference) - 4.4 Rewrite the
/devicesGET handler to renderdevices.htmlwithdevices,csrf_token,edit_record,error=None; keep the existingconfig_store.list/config_store.getcalls - 4.5 Rewrite the
/devices/savePOST handler's error-path branch to renderdevices.html(same context as 4.4 witherror=<message>); keep the success-pathRedirectResponse(url="/devices", status_code=303)unchanged - 4.6 Rewrite the
/accountGET and POST handlers to renderaccount.htmlwithcsrf_token,message,errorin the context; preserve the password-change success/error branches - 4.7 Rewrite the
/historyGET handler to renderhistory.htmlwithentriesin the context - 4.8 Confirm
/api/status(JSON) and all POST handlers that issueRedirectResponse(/login,/logout,/devices/savesuccess,/devices/remove) are unchanged in behavior — only the GET and error-render paths swap from f-string to template render
5. Cleanup of dead code
- 5.1 Remove the
escape()helper fromhost_agent/web/app.py - 5.2 Remove
_chrome()fromhost_agent/web/app.py - 5.3 Remove the
_CSSconstant fromhost_agent/web/app.py - 5.4 Remove the
_login_page,_dashboard_body,_devices_body,_account_body,_history_bodyfunctions fromhost_agent/web/app.py - 5.5 Remove the now-unused
from html import escape as _escapeimport - 5.6 Grep
apps/device-host-agent/for any remainingescape(,_chrome(,_CSS,_dashboard_body,_devices_body,_account_body,_history_body,_login_pagereferences and remove any stragglers; only_ENV/get_template/renderreferences should remain inhost_agent/web/app.py
6. Tests
- 6.1 Create
apps/device-host-agent/tests/host_agent/web/__init__.pyif not present (empty) - 6.2 Create
apps/device-host-agent/tests/host_agent/web/conftest.pywith fixtures exposing the module-level_ENVfromhost_agent.web.appand helper factories for representative contexts (one valid session, one no-account state, two sample devices with one containing the XSS probe, one history entry, one edit_record) - 6.3 Create
apps/device-host-agent/tests/host_agent/web/test_templates.pywith one render-smoke test per template (login, dashboard, devices, account, history) asserting the rendered HTML contains a stable selector from each section (e.g.id="last-heartbeat"for dashboard, the<form method="post" action="/devices/save">for devices) and that rendering does not raise for the representative context - 6.4 Add an XSS-probe test that, for each of
dashboard.html,devices.html,history.html, renders the template with a context in which every operator-influenced field is set to<script>alert(1)</script>(device name, device driver_type, connection_info JSON string, history summary) and asserts the substring<script>alert(1)</script>does not appear in the output while<script>alert(1)</script>does - 6.5 Add a byte-identity test that loads the dashboard template, renders it with a representative context, and asserts the inline
<script>block body extracted from the output equals the contents oftests/host_agent/web/__baseline__/dashboard_script.txtcaptured in 2.2 (normalize trailing whitespace) - 6.6 Add a no-autoescape-bypass test that greps the templates directory for the literal substrings
| safe,{% autoescape false %}, and{% endautoescape %}, asserting zero matches across all current templates (guards against a future change silently opting out) - 6.7 Add a test asserting
_ENV.autoescapeis configured for.htmltemplates (e.g. by rendering a template whose name ends in.htmlwith a probe value and confirming the output is escaped — concrete and future-proof against Environment construction changes)
7. Local validation
- 7.1 Run
uv run --package device-host-agent pytestand confirm the full package suite (existing tests plus the new section-6 tests) passes - 7.2 Run the root non-integration suite (
uv run --all-packages pytest -m "not integration") and confirm no regressions versus the pre-change baseline - 7.3 Run
ruff check --fixandruff formatoverapps/device-host-agent/host_agent/web/,apps/device-host-agent/tests/host_agent/web/, andapps/device-host-agent/pyproject.toml; resolve any findings - 7.4 Run
python -m compileall apps/device-host-agent/host_agent/web apps/device-host-agent/tests/host_agent/weband confirm no syntax errors - 7.5 Run
git diff --checkto catch whitespace errors before commit - 7.6 Run
openspec validate host-agent-console-jinja2-templates --strictand confirm it passes
8. Manual browser verification
- 8.1 Start a Host Agent locally (
uv run --package device-host-agent device-host-agentor the existing run command documented indocs/CLOUD_DEPLOYMENT.md); openhttp://127.0.0.1:8765/loginin a browser and log in with the existing local account - 8.2 On the status dashboard, confirm the auto-refresh polling still works (watch the "Last heartbeat" line update at the 5-second cadence) and the inline
<script>executes without console errors - 8.3 Visit
/devices, add a device whose name is<script>alert(1)</script>(and a valid driver type/connection info); confirm the device appears in the list with the script visible as text rather than executing, then remove it - 8.4 Visit
/account, change the password (current → new → confirm) end-to-end, log out, and log back in with the new password - 8.5 Visit
/historyand confirm recent entries render - 8.6 Compare each page's visual layout to the pre-change version and confirm no styling regression (the
_CSScontent must be byte-identical insidebase.html's<style>block)
9. Coordination follow-up (not blocking archive of this change)
- 9.1 After this change lands on
master, open a follow-up note (issue or PR comment) onopenspec/changes/task-execution-progress-visibilityadvising that its design.md D3 ("reuseapi/console.py's query patterns and f-string +html.escape()convention") is superseded: the task list/detail/timeline pages planned there SHOULD be written directly against the Jinja2 templates introduced here. This task does not block archive of either change; it only prevents the next author from re-growing the f-string pattern.