feat(host-agent): migrate local console to Jinja2 templates with autoescape
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>
This commit is contained in:
2026-07-14 13:05:19 +08:00
co-authored by Claude Opus 4.6
parent ec261d57c2
commit 8381b3068a
20 changed files with 1286 additions and 394 deletions
+177 -394
View File
@@ -3,10 +3,10 @@ from __future__ import annotations
import asyncio
import base64
import json
from html import escape as _escape
from pathlib import Path
from typing import Any
import jinja2
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
@@ -16,8 +16,8 @@ from host_agent.client import HostAgentEnrollmentClient
from host_agent.config import HostAgentConfig
from host_agent.devices import register_local_device, unregister_local_device
from host_agent.history import ConsoleHistoryStore
from host_agent.identity import HostIdentityState, HostIdentityStore
from host_agent.local_account import LocalAccountState, LocalAccountStore
from host_agent.identity import HostIdentityStore
from host_agent.local_account import LocalAccountStore
from host_agent.status import AgentStatusTracker
from host_agent.web.auth import (
SessionManager,
@@ -34,84 +34,20 @@ CSRF_HEADER_NAME = "X-CSRF-Token"
CSRF_FORM_FIELD = "csrf_token"
_LOOPBACK_BIND_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
_CSS = """
body { font-family: system-ui, sans-serif; margin: 0; background: #f5f5f5; color: #222; }
header { background: #20303f; color: #fff; padding: 0.75rem 1.5rem; }
header nav { display: inline; margin-left: 1.5rem; }
header nav a, header nav form { display: inline-block; margin-right: 1rem; }
header a { color: #fff; text-decoration: none; }
header button { background: none; border: none; color: #fff; text-decoration: underline; cursor: pointer; padding: 0; font: inherit; }
main { padding: 1.5rem; max-width: 960px; margin: 0 auto; }
table { border-collapse: collapse; width: 100%; margin-bottom: 1rem; background: #fff; }
th, td { border: 1px solid #ccc; padding: 0.4rem 0.6rem; text-align: left; }
form.inline { display: inline; margin: 0; }
.error { color: #b00020; }
.notice { color: #1b5e20; }
"""
_ENV = jinja2.Environment(
loader=jinja2.FileSystemLoader(Path(__file__).parent / "templates"),
autoescape=jinja2.select_autoescape(["html", "xml"]),
)
def escape(value: object) -> str:
if value is None:
return ""
return _escape(str(value), quote=True)
def _chrome(title: str, body_html: str, *, session: SessionState | None) -> str:
nav = ""
if session is not None:
nav = f"""
<nav>
<a href="/">Status</a>
<a href="/devices">Devices</a>
<a href="/tasks">Tasks</a>
<a href="/account">Account</a>
<a href="/history">History</a>
<form class="inline" method="post" action="/logout">
<input type="hidden" name="{CSRF_FORM_FIELD}" value="{escape(session.csrf_token)}">
<button type="submit">Logout</button>
</form>
</nav>
"""
return f"""<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>{escape(title)}</title>
<style>{_CSS}</style>
</head>
<body>
<header>
<strong>Host Agent Console</strong>
{nav}
</header>
<main>
{body_html}
</main>
</body>
</html>"""
def _login_page(
*, account: LocalAccountState | None, error: str | None = None
def _render(
template_name: str,
*,
status_code: int = 200,
**context: Any,
) -> HTMLResponse:
if account is None:
body = """
<h1>Login</h1>
<p>No local account exists yet. Run <code>device-host-agent setup</code>
on this machine to create one before logging in to the console.</p>
"""
return HTMLResponse(_chrome("Login", body, session=None))
error_html = f'<p class="error">{escape(error)}</p>' if error else ""
body = f"""
<h1>Login</h1>
{error_html}
<form method="post" action="/login">
<label>Username <input type="text" name="username" required></label><br>
<label>Password <input type="password" name="password" required></label><br>
<button type="submit">Log in</button>
</form>
"""
return HTMLResponse(_chrome("Login", body, session=None))
html = _ENV.get_template(template_name).render(**context)
return HTMLResponse(html, status_code=status_code)
def _device_display_status(device: Any, *, busy_device_id: str | None) -> str:
@@ -125,294 +61,52 @@ def _device_display_status(device: Any, *, busy_device_id: str | None) -> str:
return device.status
def _dashboard_body(
*,
identity: HostIdentityState | None,
snapshot: dict[str, Any],
devices: list[Any],
config: HostAgentConfig,
) -> str:
def _screenshot_data_uri(record: dict[str, Any]) -> str | None:
"""Return a ``data:`` URI for the step's screenshot, or ``None``."""
screenshot_path = record.get("screenshot_path")
if not screenshot_path:
return None
path = Path(str(screenshot_path))
if not path.exists():
return None
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
return f"data:image/png;base64,{encoded}"
def _dashboard_texts(*, snapshot: dict[str, Any]) -> dict[str, str]:
"""Pre-compute human-readable text strings for the dashboard template."""
heartbeat = snapshot.get("last_heartbeat")
assignment = snapshot.get("current_assignment")
policy = snapshot.get("host_policy")
busy_device_id = assignment["device_id"] if assignment else None
heartbeat_text = (
f"{'ok' if heartbeat['ok'] else 'failed'} at {heartbeat['at']} "
f"({heartbeat['device_count']} devices)"
if heartbeat
else "never"
)
assignment_text = (
f"{assignment['task_id']} on {assignment['device_id']} "
f"(started {assignment['started_at']})"
if assignment
else "none"
)
progress = snapshot.get("progress")
progress_text = (
f"step {progress['step_index']}{progress['step_status']}: {progress['summary']}"
if progress
else ""
)
policy_text = (
"revision {revision}; self-submission {self_submission}; "
"max active tasks {max_active}; daily token budget {daily_budget}".format(
revision=policy["revision"],
self_submission="enabled"
if policy["self_submission_enabled"]
else "disabled",
max_active=policy["max_active_tasks"] or "unlimited",
daily_budget=policy["daily_token_budget"] or "unmetered",
)
if policy
else "no Cloud policy cached"
)
device_rows = "".join(
f"<tr><td>{escape(device.id)}</td><td>{escape(device.name or '')}</td>"
f"<td>{escape(device.driver_type)}</td>"
f"<td>{escape(_device_display_status(device, busy_device_id=busy_device_id))}</td></tr>"
for device in devices
)
return f"""
<h1>Status</h1>
<section>
<h2>Enrollment</h2>
<p>Host ID: {escape(identity.host_id if identity else None) or "not enrolled"}</p>
<p>Agent instance ID: {escape(identity.agent_instance_id if identity else None) or "unknown"}</p>
<p>Control plane: {escape(config.control_plane_url)}</p>
</section>
<section>
<h2>Heartbeat</h2>
<p id="last-heartbeat">{escape(heartbeat_text)}</p>
</section>
<section>
<h2>Cloud policy</h2>
<p id="host-policy">{escape(policy_text)}</p>
</section>
<section>
<h2>Current assignment</h2>
<p id="current-assignment">{escape(assignment_text)}</p>
<p id="current-progress">{escape(progress_text)}</p>
</section>
<section>
<h2>Devices</h2>
<table>
<thead><tr><th>ID</th><th>Name</th><th>Driver</th><th>Status</th></tr></thead>
<tbody id="device-status-body">{device_rows}</tbody>
</table>
</section>
<script>
(function () {{
function render(data) {{
var hb = data.status.last_heartbeat;
document.getElementById("last-heartbeat").textContent = hb
? (hb.ok ? "ok" : "failed") + " at " + hb.at + " (" + hb.device_count + " devices)"
: "never";
var current = data.status.current_assignment;
document.getElementById("current-assignment").textContent = current
? current.task_id + " on " + current.device_id + " (started " + current.started_at + ")"
: "none";
var progress = data.status.progress;
document.getElementById("current-progress").textContent = progress
? "step " + progress.step_index + " \\u2014 " + progress.step_status + ": " + progress.summary
: "";
var policy = data.status.host_policy;
document.getElementById("host-policy").textContent = policy
? "revision " + policy.revision + "; self-submission "
+ (policy.self_submission_enabled ? "enabled" : "disabled")
+ "; max active tasks " + (policy.max_active_tasks || "unlimited")
+ "; daily token budget " + (policy.daily_token_budget || "unmetered")
: "no Cloud policy cached";
var body = document.getElementById("device-status-body");
body.innerHTML = "";
data.devices.forEach(function (device) {{
var row = document.createElement("tr");
["id", "name", "driver_type", "status"].forEach(function (key) {{
var cell = document.createElement("td");
cell.textContent = device[key] || "";
row.appendChild(cell);
}});
body.appendChild(row);
}});
}}
function poll() {{
fetch("/api/status", {{ credentials: "same-origin" }})
.then(function (response) {{ return response.ok ? response.json() : null; }})
.then(function (data) {{ if (data) render(data); }})
.catch(function () {{}});
}}
setInterval(poll, 5000);
}})();
</script>
"""
def _devices_body(
*,
devices: list[dict[str, Any]],
csrf_token: str,
edit_record: dict[str, Any] | None,
error: str | None,
) -> str:
error_html = f'<p class="error">{escape(error)}</p>' if error else ""
rows = "".join(
f"""
<tr>
<td>{escape(device["device_id"])}</td>
<td>{escape(device["name"] or "")}</td>
<td>{escape(device["driver_type"])}</td>
<td>{escape(device["cloud_device_id"] or "")}</td>
<td>
<a href="/devices?edit={escape(device["device_id"])}">Edit</a>
<form class="inline" method="post" action="/devices/remove">
<input type="hidden" name="{CSRF_FORM_FIELD}" value="{escape(csrf_token)}">
<input type="hidden" name="device_id" value="{escape(device["device_id"])}">
<button type="submit">Remove</button>
</form>
</td>
</tr>
"""
for device in devices
)
form_device_id = escape(edit_record["device_id"]) if edit_record else ""
form_name = escape(edit_record["name"] or "") if edit_record else ""
form_driver_type = escape(edit_record["driver_type"]) if edit_record else "wda"
form_connection_info = (
escape(json.dumps(edit_record["connection_info"])) if edit_record else "{}"
)
return f"""
<h1>Devices</h1>
{error_html}
<table>
<thead><tr><th>ID</th><th>Name</th><th>Driver</th><th>Cloud ID</th><th></th></tr></thead>
<tbody>{rows}</tbody>
</table>
<h2>{"Edit device" if edit_record else "Add device"}</h2>
<form method="post" action="/devices/save">
<input type="hidden" name="{CSRF_FORM_FIELD}" value="{escape(csrf_token)}">
<label>Device ID <input type="text" name="device_id" value="{form_device_id}" required></label><br>
<label>Name <input type="text" name="name" value="{form_name}"></label><br>
<label>Driver type <input type="text" name="driver_type" value="{form_driver_type}" required></label><br>
<label>Connection info (JSON)<br>
<textarea name="connection_info" rows="3" cols="50">{form_connection_info}</textarea>
</label><br>
<button type="submit">Save</button>
</form>
"""
def _account_body(*, csrf_token: str, message: str | None, error: str | None) -> str:
message_html = f'<p class="notice">{escape(message)}</p>' if message else ""
error_html = f'<p class="error">{escape(error)}</p>' if error else ""
return f"""
<h1>Account</h1>
{message_html}
{error_html}
<form method="post" action="/account">
<input type="hidden" name="{CSRF_FORM_FIELD}" value="{escape(csrf_token)}">
<label>Current password <input type="password" name="current_password" required></label><br>
<label>New password <input type="password" name="new_password" required></label><br>
<label>Confirm new password <input type="password" name="confirm_password" required></label><br>
<button type="submit">Change password</button>
</form>
"""
def _history_body(entries: list[dict[str, Any]]) -> str:
rows = "".join(
f"<tr><td>{escape(entry['occurred_at'])}</td><td>{escape(entry['kind'])}</td>"
f"<td>{escape(entry['summary'])}</td></tr>"
for entry in entries
)
return f"""
<h1>History</h1>
<table>
<thead><tr><th>Time</th><th>Kind</th><th>Summary</th></tr></thead>
<tbody>{rows}</tbody>
</table>
"""
def _inline_screenshot(record: dict[str, Any]) -> str:
"""Return a ``<img>`` tag with base64-encoded screenshot, or empty string."""
screenshot_path = record.get("screenshot_path")
if not screenshot_path:
return ""
path = Path(str(screenshot_path))
if not path.exists():
return ""
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
return f'<img src="data:image/png;base64,{encoded}" alt="screenshot" style="max-width:100%;border:1px solid #ccc;margin-top:0.5rem;">'
def _tasks_body(tasks: list[dict[str, Any]]) -> str:
if not tasks:
return """
<h1>Tasks</h1>
<p>No tasks recorded.</p>
"""
rows = "".join(
f"<tr>"
f'<td><a href="/tasks/{escape(task["id"])}">{escape(task["id"])}</a></td>'
f"<td>{escape(task.get('status') or '')}</td>"
f"<td>{escape(task.get('device_id') or '')}</td>"
f"<td>{escape(task.get('created_at') or '')}</td>"
f"<td>{escape(task.get('updated_at') or '')}</td>"
f"</tr>"
for task in tasks
)
return f"""
<h1>Tasks</h1>
<table>
<thead><tr><th>Task ID</th><th>Status</th><th>Device</th><th>Created</th><th>Updated</th></tr></thead>
<tbody>{rows}</tbody>
</table>
"""
def _task_detail_body(
task: dict[str, Any], timeline_records: list[dict[str, Any]]
) -> str:
task_rows = "".join(
f"<tr><td>{escape(key)}</td><td>{escape(task[key])}</td></tr>"
for key in ("id", "goal", "device_id", "status", "created_at", "updated_at")
if task.get(key) is not None
)
metadata_html = f"""
<h1>Task {escape(task.get("id") or "")}</h1>
<table>
<thead><tr><th>Field</th><th>Value</th></tr></thead>
<tbody>{task_rows}</tbody>
</table>
"""
if not timeline_records:
timeline_html = "<h2>Timeline</h2><p>No timeline records.</p>"
else:
step_blocks = "".join(
_timeline_step_html(record) for record in timeline_records
)
timeline_html = f"<h2>Timeline</h2>{step_blocks}"
return metadata_html + timeline_html
def _timeline_step_html(record: dict[str, Any]) -> str:
index = record.get("index", "")
timestamp = record.get("timestamp", "")
tool_call = record.get("tool_call")
result = record.get("result")
prompt = record.get("prompt") or ""
tool_call_text = json.dumps(tool_call, ensure_ascii=False) if tool_call else ""
result_text = json.dumps(result, ensure_ascii=False) if result else ""
screenshot_html = _inline_screenshot(record)
return f"""
<div style="border:1px solid #ccc;background:#fff;padding:0.75rem;margin-bottom:0.75rem;">
<p><strong>Step {escape(index)}</strong> — {escape(timestamp)}</p>
<p>Prompt: {escape(prompt)}</p>
<p>Tool call: <code>{escape(tool_call_text)}</code></p>
<p>Result: <code>{escape(result_text)}</code></p>
{screenshot_html}
</div>
"""
return {
"heartbeat_text": (
f"{'ok' if heartbeat['ok'] else 'failed'} at {heartbeat['at']} "
f"({heartbeat['device_count']} devices)"
if heartbeat
else "never"
),
"assignment_text": (
f"{assignment['task_id']} on {assignment['device_id']} "
f"(started {assignment['started_at']})"
if assignment
else "none"
),
"progress_text": (
f"step {progress['step_index']} \u2014 {progress['step_status']}: "
f"{progress['summary']}"
if progress
else ""
),
"policy_text": (
f"revision {policy['revision']}; self-submission "
f"{'enabled' if policy['self_submission_enabled'] else 'disabled'}; "
f"max active tasks {policy['max_active_tasks'] or 'unlimited'}; "
f"daily token budget {policy['daily_token_budget'] or 'unmetered'}"
if policy
else "no Cloud policy cached"
),
}
def create_console_app(
@@ -464,13 +158,13 @@ def create_console_app(
@app.get("/login", response_class=HTMLResponse)
async def login_page() -> HTMLResponse:
account = await asyncio.to_thread(local_account_store.load)
return _login_page(account=account)
return _render("login.html", title="Login", session=None, account=account)
@app.post("/login")
async def login_submit(request: Request) -> Response:
account = await asyncio.to_thread(local_account_store.load)
if account is None:
return _login_page(account=None)
return _render("login.html", title="Login", session=None, account=None)
form = await request.form()
username = str(form.get("username", ""))
password = str(form.get("password", ""))
@@ -478,7 +172,13 @@ def create_console_app(
attempt_login, local_account_store, username=username, password=password
)
if not ok:
return _login_page(account=account, error="Invalid username or password.")
return _render(
"login.html",
title="Login",
session=None,
account=account,
error="Invalid username or password.",
)
session_token, _ = session_manager.create_session(username)
response = RedirectResponse(url="/", status_code=303)
response.set_cookie(
@@ -509,18 +209,38 @@ def create_console_app(
) -> HTMLResponse:
identity = await asyncio.to_thread(identity_store.load)
snapshot = status_tracker.snapshot()
devices = manager.list_devices()
body = _dashboard_body(
identity=identity, snapshot=snapshot, devices=devices, config=config
busy_device_id = (
snapshot["current_assignment"]["device_id"]
if snapshot.get("current_assignment")
else None
)
devices = [
{
"id": d.id,
"name": d.name,
"driver_type": d.driver_type,
"display_status": _device_display_status(
d, busy_device_id=busy_device_id
),
}
for d in manager.list_devices()
]
texts = _dashboard_texts(snapshot=snapshot)
return _render(
"dashboard.html",
title="Status",
session=session,
identity=identity,
devices=devices,
config=config,
**texts,
)
return HTMLResponse(_chrome("Status", body, session=session))
@app.get("/api/status")
async def api_status(
session: SessionState = Depends(require_session),
) -> JSONResponse:
snapshot = status_tracker.snapshot()
# Pull live progress from the executor if the tracker has no data yet.
if executor is not None and snapshot.get("progress") is None:
live = executor.latest_progress()
if live is not None:
@@ -553,13 +273,19 @@ def create_console_app(
edit_record = (
await asyncio.to_thread(config_store.get, edit_id) if edit_id else None
)
body = _devices_body(
connection_info_json = (
json.dumps(edit_record["connection_info"]) if edit_record else "{}"
)
return _render(
"devices.html",
title="Devices",
session=session,
devices=devices,
csrf_token=session.csrf_token,
edit_record=edit_record,
connection_info_json=connection_info_json,
error=None,
)
return HTMLResponse(_chrome("Devices", body, session=session))
@app.post("/devices/save")
async def devices_save(
@@ -605,14 +331,16 @@ def create_console_app(
if error is not None:
devices = await asyncio.to_thread(config_store.list)
body = _devices_body(
return _render(
"devices.html",
title="Devices",
session=session,
devices=devices,
csrf_token=session.csrf_token,
edit_record=None,
connection_info_json="{}",
error=error,
)
return HTMLResponse(
_chrome("Devices", body, session=session), status_code=400
status_code=400,
)
return RedirectResponse(url="/devices", status_code=303)
@@ -633,8 +361,14 @@ def create_console_app(
async def account_page(
session: SessionState = Depends(require_session),
) -> HTMLResponse:
body = _account_body(csrf_token=session.csrf_token, message=None, error=None)
return HTMLResponse(_chrome("Account", body, session=session))
return _render(
"account.html",
title="Account",
session=session,
csrf_token=session.csrf_token,
message=None,
error=None,
)
@app.post("/account", response_class=HTMLResponse)
async def account_submit(
@@ -646,13 +380,14 @@ def create_console_app(
new_password = str(form.get("new_password", ""))
confirm_password = str(form.get("confirm_password", ""))
if not new_password or new_password != confirm_password:
body = _account_body(
return _render(
"account.html",
title="Account",
session=session,
csrf_token=session.csrf_token,
message=None,
error="New password and confirmation must match.",
)
return HTMLResponse(
_chrome("Account", body, session=session), status_code=400
status_code=400,
)
ok = await asyncio.to_thread(
change_password,
@@ -661,28 +396,35 @@ def create_console_app(
new_password=new_password,
)
if not ok:
body = _account_body(
return _render(
"account.html",
title="Account",
session=session,
csrf_token=session.csrf_token,
message=None,
error="Current password is incorrect.",
status_code=400,
)
return HTMLResponse(
_chrome("Account", body, session=session), status_code=400
)
body = _account_body(
return _render(
"account.html",
title="Account",
session=session,
csrf_token=session.csrf_token,
message="Password updated.",
error=None,
)
return HTMLResponse(_chrome("Account", body, session=session))
@app.get("/history", response_class=HTMLResponse)
async def history_page(
session: SessionState = Depends(require_session),
) -> HTMLResponse:
entries = await asyncio.to_thread(history_store.list_recent)
body = _history_body(entries)
return HTMLResponse(_chrome("History", body, session=session))
return _render(
"history.html",
title="History",
session=session,
entries=entries,
)
@app.get("/tasks", response_class=HTMLResponse)
async def tasks_page(
@@ -693,8 +435,12 @@ def create_console_app(
status_code=503, detail="task metadata store not configured"
)
tasks_list = await asyncio.to_thread(metadata_store.list_tasks)
body = _tasks_body(tasks_list)
return HTMLResponse(_chrome("Tasks", body, session=session))
return _render(
"tasks_list.html",
title="Tasks",
session=session,
tasks=tasks_list,
)
@app.get("/tasks/{task_id}", response_class=HTMLResponse)
async def task_detail_page(
@@ -711,7 +457,44 @@ def create_console_app(
timeline_records: list[dict[str, Any]] = []
if timeline is not None:
timeline_records = await asyncio.to_thread(timeline.read, task_id)
body = _task_detail_body(task, timeline_records)
return HTMLResponse(_chrome(f"Task {task_id}", body, session=session))
task_rows = [
(key, task[key])
for key in (
"id",
"goal",
"device_id",
"status",
"created_at",
"updated_at",
)
if task.get(key) is not None
]
timeline_steps = [
{
"index": record.get("index", ""),
"timestamp": record.get("timestamp", ""),
"prompt": record.get("prompt") or "",
"tool_call_text": (
json.dumps(record.get("tool_call"), ensure_ascii=False)
if record.get("tool_call")
else ""
),
"result_text": (
json.dumps(record.get("result"), ensure_ascii=False)
if record.get("result")
else ""
),
"screenshot_src": _screenshot_data_uri(record),
}
for record in timeline_records
]
return _render(
"task_detail.html",
title=f"Task {task_id}",
session=session,
task=task,
task_rows=task_rows,
timeline_steps=timeline_steps,
)
return app
@@ -0,0 +1,17 @@
{% extends "base.html" %}
{% block body %}
<h1>Account</h1>
{% if message %}
<p class="notice">{{ message }}</p>
{% endif %}
{% if error %}
<p class="error">{{ error }}</p>
{% endif %}
<form method="post" action="/account">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Current password <input type="password" name="current_password" required></label><br>
<label>New password <input type="password" name="new_password" required></label><br>
<label>Confirm new password <input type="password" name="confirm_password" required></label><br>
<button type="submit">Change password</button>
</form>
{% endblock %}
@@ -0,0 +1,40 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>{{ title }}</title>
<style>{% block styles %}body { font-family: system-ui, sans-serif; margin: 0; background: #f5f5f5; color: #222; }
header { background: #20303f; color: #fff; padding: 0.75rem 1.5rem; }
header nav { display: inline; margin-left: 1.5rem; }
header nav a, header nav form { display: inline-block; margin-right: 1rem; }
header a { color: #fff; text-decoration: none; }
header button { background: none; border: none; color: #fff; text-decoration: underline; cursor: pointer; padding: 0; font: inherit; }
main { padding: 1.5rem; max-width: 960px; margin: 0 auto; }
table { border-collapse: collapse; width: 100%; margin-bottom: 1rem; background: #fff; }
th, td { border: 1px solid #ccc; padding: 0.4rem 0.6rem; text-align: left; }
form.inline { display: inline; margin: 0; }
.error { color: #b00020; }
.notice { color: #1b5e20; }{% endblock %}</style>
</head>
<body>
<header>
<strong>Host Agent Console</strong>
{% block nav %}{% if session %}
<nav>
<a href="/">Status</a>
<a href="/devices">Devices</a>
<a href="/tasks">Tasks</a>
<a href="/account">Account</a>
<a href="/history">History</a>
<form class="inline" method="post" action="/logout">
<input type="hidden" name="csrf_token" value="{{ session.csrf_token }}">
<button type="submit">Logout</button>
</form>
</nav>
{% endif %}{% endblock %}
</header>
<main>
{% block body %}{% endblock %}
</main>
</body>
</html>
@@ -0,0 +1,73 @@
{% extends "base.html" %}
{% block body %}
<h1>Status</h1>
<section>
<h2>Enrollment</h2>
<p>Host ID: {{ identity.host_id if identity else "" or "not enrolled" }}</p>
<p>Agent instance ID: {{ identity.agent_instance_id if identity else "" or "unknown" }}</p>
<p>Control plane: {{ config.control_plane_url }}</p>
</section>
<section>
<h2>Heartbeat</h2>
<p id="last-heartbeat">{{ heartbeat_text }}</p>
</section>
<section>
<h2>Cloud policy</h2>
<p id="host-policy">{{ policy_text }}</p>
</section>
<section>
<h2>Current assignment</h2>
<p id="current-assignment">{{ assignment_text }}</p>
<p id="current-progress">{{ progress_text }}</p>
</section>
<section>
<h2>Devices</h2>
<table>
<thead><tr><th>ID</th><th>Name</th><th>Driver</th><th>Status</th></tr></thead>
<tbody id="device-status-body">{% for device in devices %}<tr><td>{{ device.id }}</td><td>{{ device.name or "" }}</td><td>{{ device.driver_type }}</td><td>{{ device.display_status }}</td></tr>{% endfor %}</tbody>
</table>
</section>
{% raw %}<script>
(function () {
function render(data) {
var hb = data.status.last_heartbeat;
document.getElementById("last-heartbeat").textContent = hb
? (hb.ok ? "ok" : "failed") + " at " + hb.at + " (" + hb.device_count + " devices)"
: "never";
var current = data.status.current_assignment;
document.getElementById("current-assignment").textContent = current
? current.task_id + " on " + current.device_id + " (started " + current.started_at + ")"
: "none";
var progress = data.status.progress;
document.getElementById("current-progress").textContent = progress
? "step " + progress.step_index + " \u2014 " + progress.step_status + ": " + progress.summary
: "";
var policy = data.status.host_policy;
document.getElementById("host-policy").textContent = policy
? "revision " + policy.revision + "; self-submission "
+ (policy.self_submission_enabled ? "enabled" : "disabled")
+ "; max active tasks " + (policy.max_active_tasks || "unlimited")
+ "; daily token budget " + (policy.daily_token_budget || "unmetered")
: "no Cloud policy cached";
var body = document.getElementById("device-status-body");
body.innerHTML = "";
data.devices.forEach(function (device) {
var row = document.createElement("tr");
["id", "name", "driver_type", "status"].forEach(function (key) {
var cell = document.createElement("td");
cell.textContent = device[key] || "";
row.appendChild(cell);
});
body.appendChild(row);
});
}
function poll() {
fetch("/api/status", { credentials: "same-origin" })
.then(function (response) { return response.ok ? response.json() : null; })
.then(function (data) { if (data) render(data); })
.catch(function () {});
}
setInterval(poll, 5000);
})();
</script>{% endraw %}
{% endblock %}
@@ -0,0 +1,37 @@
{% extends "base.html" %}
{% block body %}
<h1>Devices</h1>
{% if error %}
<p class="error">{{ error }}</p>
{% endif %}
<table>
<thead><tr><th>ID</th><th>Name</th><th>Driver</th><th>Cloud ID</th><th></th></tr></thead>
<tbody>{% for device in devices %}
<tr>
<td>{{ device["device_id"] }}</td>
<td>{{ device["name"] or "" }}</td>
<td>{{ device["driver_type"] }}</td>
<td>{{ device["cloud_device_id"] or "" }}</td>
<td>
<a href="/devices?edit={{ device["device_id"] }}">Edit</a>
<form class="inline" method="post" action="/devices/remove">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="device_id" value="{{ device["device_id"] }}">
<button type="submit">Remove</button>
</form>
</td>
</tr>
{% endfor %}</tbody>
</table>
<h2>{{ "Edit device" if edit_record else "Add device" }}</h2>
<form method="post" action="/devices/save">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Device ID <input type="text" name="device_id" value="{{ edit_record["device_id"] if edit_record else "" }}" required></label><br>
<label>Name <input type="text" name="name" value="{{ edit_record["name"] if edit_record else "" }}"></label><br>
<label>Driver type <input type="text" name="driver_type" value="{{ edit_record["driver_type"] if edit_record else "wda" }}" required></label><br>
<label>Connection info (JSON)<br>
<textarea name="connection_info" rows="3" cols="50">{{ connection_info_json }}</textarea>
</label><br>
<button type="submit">Save</button>
</form>
{% endblock %}
@@ -0,0 +1,8 @@
{% extends "base.html" %}
{% block body %}
<h1>History</h1>
<table>
<thead><tr><th>Time</th><th>Kind</th><th>Summary</th></tr></thead>
<tbody>{% for entry in entries %}<tr><td>{{ entry["occurred_at"] }}</td><td>{{ entry["kind"] }}</td><td>{{ entry["summary"] }}</td></tr>{% endfor %}</tbody>
</table>
{% endblock %}
@@ -0,0 +1,18 @@
{% extends "base.html" %}
{% block nav %}{% endblock %}
{% block body %}
<h1>Login</h1>
{% if not account %}
<p>No local account exists yet. Run <code>device-host-agent setup</code>
on this machine to create one before logging in to the console.</p>
{% else %}
{% if error %}
<p class="error">{{ error }}</p>
{% endif %}
<form method="post" action="/login">
<label>Username <input type="text" name="username" required></label><br>
<label>Password <input type="password" name="password" required></label><br>
<button type="submit">Log in</button>
</form>
{% endif %}
{% endblock %}
@@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block body %}
<h1>Task {{ task.get("id") or "" }}</h1>
<table>
<thead><tr><th>Field</th><th>Value</th></tr></thead>
<tbody>{% for row in task_rows %}<tr><td>{{ row[0] }}</td><td>{{ row[1] }}</td></tr>{% endfor %}</tbody>
</table>
<h2>Timeline</h2>
{% if not timeline_steps %}
<p>No timeline records.</p>
{% else %}
{% for step in timeline_steps %}
<div style="border:1px solid #ccc;background:#fff;padding:0.75rem;margin-bottom:0.75rem;">
<p><strong>Step {{ step.index }}</strong> &mdash; {{ step.timestamp }}</p>
<p>Prompt: {{ step.prompt }}</p>
<p>Tool call: <code>{{ step.tool_call_text }}</code></p>
<p>Result: <code>{{ step.result_text }}</code></p>
{% if step.screenshot_src %}<img src="{{ step.screenshot_src }}" alt="screenshot" style="max-width:100%;border:1px solid #ccc;margin-top:0.5rem;">{% endif %}
</div>
{% endfor %}
{% endif %}
{% endblock %}
@@ -0,0 +1,20 @@
{% extends "base.html" %}
{% block body %}
<h1>Tasks</h1>
{% if not tasks %}
<p>No tasks recorded.</p>
{% else %}
<table>
<thead><tr><th>Task ID</th><th>Status</th><th>Device</th><th>Created</th><th>Updated</th></tr></thead>
<tbody>{% for task in tasks %}
<tr>
<td><a href="/tasks/{{ task["id"] }}">{{ task["id"] }}</a></td>
<td>{{ task.get("status") or "" }}</td>
<td>{{ task.get("device_id") or "" }}</td>
<td>{{ task.get("created_at") or "" }}</td>
<td>{{ task.get("updated_at") or "" }}</td>
</tr>
{% endfor %}</tbody>
</table>
{% endif %}
{% endblock %}
+4
View File
@@ -8,6 +8,7 @@ dependencies = [
"device-cloud-platform==0.1.0",
"fastapi>=0.115.0",
"httpx>=0.27.0",
"jinja2>=3.1",
"uvicorn[standard]>=0.30.0",
]
@@ -22,6 +23,9 @@ build-backend = "setuptools.build_meta"
where = ["."]
include = ["host_agent*"]
[tool.setuptools.package-data]
"host_agent.web" = ["templates/*.html"]
[tool.uv.sources]
device-agent-runtime = { workspace = true }
device-cloud-platform = { workspace = true }
@@ -0,0 +1,43 @@
<script>
(function () {
function render(data) {
var hb = data.status.last_heartbeat;
document.getElementById("last-heartbeat").textContent = hb
? (hb.ok ? "ok" : "failed") + " at " + hb.at + " (" + hb.device_count + " devices)"
: "never";
var current = data.status.current_assignment;
document.getElementById("current-assignment").textContent = current
? current.task_id + " on " + current.device_id + " (started " + current.started_at + ")"
: "none";
var progress = data.status.progress;
document.getElementById("current-progress").textContent = progress
? "step " + progress.step_index + " \u2014 " + progress.step_status + ": " + progress.summary
: "";
var policy = data.status.host_policy;
document.getElementById("host-policy").textContent = policy
? "revision " + policy.revision + "; self-submission "
+ (policy.self_submission_enabled ? "enabled" : "disabled")
+ "; max active tasks " + (policy.max_active_tasks || "unlimited")
+ "; daily token budget " + (policy.daily_token_budget || "unmetered")
: "no Cloud policy cached";
var body = document.getElementById("device-status-body");
body.innerHTML = "";
data.devices.forEach(function (device) {
var row = document.createElement("tr");
["id", "name", "driver_type", "status"].forEach(function (key) {
var cell = document.createElement("td");
cell.textContent = device[key] || "";
row.appendChild(cell);
});
body.appendChild(row);
});
}
function poll() {
fetch("/api/status", { credentials: "same-origin" })
.then(function (response) { return response.ok ? response.json() : null; })
.then(function (data) { if (data) render(data); })
.catch(function () {});
}
setInterval(poll, 5000);
})();
</script>
@@ -0,0 +1,230 @@
"""Shared fixtures and context factories for Jinja2 template tests."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
import pytest
from host_agent.config import HostAgentConfig
from host_agent.web.app import _ENV
from host_agent.web.auth import SessionState
XSS_PROBE = "<script>alert(1)</script>"
_ESCAPED_PROBE = "&lt;script&gt;alert(1)&lt;/script&gt;"
@pytest.fixture
def env() -> Any:
"""The module-level Jinja2 Environment from host_agent.web.app."""
return _ENV
@pytest.fixture
def sample_session() -> SessionState:
"""A representative logged-in session."""
return SessionState(
username="operator",
csrf_token="test-csrf-token",
expires_at=datetime(2030, 1, 1, tzinfo=UTC),
)
@pytest.fixture
def xss_probe() -> str:
return XSS_PROBE
# ---------------------------------------------------------------------------
# Context factories
# ---------------------------------------------------------------------------
def make_login_context(
*, account: Any = None, error: str | None = None
) -> dict[str, Any]:
return {
"title": "Login",
"session": None,
"account": account,
"error": error,
}
def make_dashboard_context(
session: SessionState,
*,
devices: list[dict[str, Any]] | None = None,
identity: Any = None,
heartbeat_text: str = "never",
assignment_text: str = "none",
progress_text: str = "",
policy_text: str = "no Cloud policy cached",
config: HostAgentConfig | None = None,
) -> dict[str, Any]:
if devices is None:
devices = [
{
"id": "dev-1",
"name": "Pixel 8",
"driver_type": "wda",
"display_status": "connected",
},
{
"id": "dev-2",
"name": "iPhone 15",
"driver_type": "wda",
"display_status": "busy",
},
]
if config is None:
config = HostAgentConfig(control_plane_url="http://localhost:8080")
return {
"title": "Status",
"session": session,
"identity": identity,
"devices": devices,
"config": config,
"heartbeat_text": heartbeat_text,
"assignment_text": assignment_text,
"progress_text": progress_text,
"policy_text": policy_text,
}
def make_devices_context(
session: SessionState,
*,
devices: list[dict[str, Any]] | None = None,
edit_record: dict[str, Any] | None = None,
connection_info_json: str = "{}",
error: str | None = None,
) -> dict[str, Any]:
if devices is None:
devices = [
{
"device_id": "dev-1",
"name": "Pixel 8",
"driver_type": "wda",
"cloud_device_id": "cloud-1",
"connection_info": {"port": 8100},
},
]
if edit_record is None:
edit_record = {
"device_id": "dev-1",
"name": "Pixel 8",
"driver_type": "wda",
"connection_info": {"port": 8100},
}
connection_info_json = '{"port": 8100}'
return {
"title": "Devices",
"session": session,
"devices": devices,
"csrf_token": session.csrf_token,
"edit_record": edit_record,
"connection_info_json": connection_info_json,
"error": error,
}
def make_account_context(
session: SessionState,
*,
message: str | None = None,
error: str | None = None,
) -> dict[str, Any]:
return {
"title": "Account",
"session": session,
"csrf_token": session.csrf_token,
"message": message,
"error": error,
}
def make_history_context(
session: SessionState,
*,
entries: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
if entries is None:
entries = [
{
"occurred_at": "2026-01-01T00:00:00Z",
"kind": "assignment",
"summary": "Task abc-123 started on dev-1",
},
]
return {
"title": "History",
"session": session,
"entries": entries,
}
def make_tasks_list_context(
session: SessionState,
*,
tasks: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
if tasks is None:
tasks = [
{
"id": "task-001",
"status": "completed",
"device_id": "dev-1",
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:05:00Z",
},
]
return {
"title": "Tasks",
"session": session,
"tasks": tasks,
}
def make_task_detail_context(
session: SessionState,
*,
task: dict[str, Any] | None = None,
task_rows: list[tuple[str, Any]] | None = None,
timeline_steps: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
if task is None:
task = {
"id": "task-001",
"goal": "Open settings",
"device_id": "dev-1",
"status": "completed",
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:05:00Z",
}
if task_rows is None:
task_rows = [
("id", task["id"]),
("goal", task["goal"]),
("device_id", task["device_id"]),
("status", task["status"]),
]
if timeline_steps is None:
timeline_steps = [
{
"index": 0,
"timestamp": "2026-01-01T00:01:00Z",
"prompt": "Tap the Settings icon",
"tool_call_text": '{"action": "tap", "x": 100, "y": 200}',
"result_text": '{"ok": true}',
"screenshot_src": None,
},
]
return {
"title": "Task task-001",
"session": session,
"task": task,
"task_rows": task_rows,
"timeline_steps": timeline_steps,
}
@@ -0,0 +1,256 @@
"""Render-smoke, XSS-probe, byte-identity, and autoescape tests for the
Host Agent console Jinja2 templates (Tasks 6.3-6.7)."""
from __future__ import annotations
from pathlib import Path
from typing import Any
import pytest
from host_agent.config import HostAgentConfig
from host_agent.web.auth import SessionState
from .conftest import (
XSS_PROBE,
_ESCAPED_PROBE,
make_account_context,
make_dashboard_context,
make_devices_context,
make_history_context,
make_login_context,
make_task_detail_context,
make_tasks_list_context,
)
# ---------------------------------------------------------------------------
# 6.3 Render-smoke tests (one per template)
# ---------------------------------------------------------------------------
def test_login_renders(env) -> None:
html = env.get_template("login.html").render(
**make_login_context(account={"username": "operator"})
)
assert '<form method="post" action="/login">' in html
def test_dashboard_renders(env, sample_session) -> None:
html = env.get_template("dashboard.html").render(
**make_dashboard_context(sample_session)
)
assert 'id="last-heartbeat"' in html
def test_devices_renders(env, sample_session) -> None:
html = env.get_template("devices.html").render(
**make_devices_context(sample_session)
)
assert '<form method="post" action="/devices/save">' in html
def test_account_renders(env, sample_session) -> None:
html = env.get_template("account.html").render(
**make_account_context(sample_session)
)
assert '<form method="post" action="/account">' in html
def test_history_renders(env, sample_session) -> None:
html = env.get_template("history.html").render(
**make_history_context(sample_session)
)
assert "<table>" in html
def test_tasks_list_renders(env, sample_session) -> None:
html = env.get_template("tasks_list.html").render(
**make_tasks_list_context(sample_session)
)
assert "<h1>Tasks</h1>" in html
def test_task_detail_renders(env, sample_session) -> None:
html = env.get_template("task_detail.html").render(
**make_task_detail_context(sample_session)
)
assert "<h2>Timeline</h2>" in html
# ---------------------------------------------------------------------------
# 6.4 XSS-probe tests (parametrised over templates with operator-influenced
# string fields set to <script>alert(1)</script>)
# ---------------------------------------------------------------------------
_TEMPLATES_WITH_XSS = ["dashboard", "devices", "history", "tasks_list", "task_detail"]
def _xss_context(template: str, session: SessionState) -> dict[str, Any]:
"""Build a context where every operator-influenced string field is the
XSS probe string."""
if template == "dashboard":
return make_dashboard_context(
session,
devices=[
{
"id": XSS_PROBE,
"name": XSS_PROBE,
"driver_type": XSS_PROBE,
"display_status": XSS_PROBE,
}
],
heartbeat_text=XSS_PROBE,
assignment_text=XSS_PROBE,
progress_text=XSS_PROBE,
policy_text=XSS_PROBE,
)
if template == "devices":
return make_devices_context(
session,
devices=[
{
"device_id": XSS_PROBE,
"name": XSS_PROBE,
"driver_type": XSS_PROBE,
"cloud_device_id": XSS_PROBE,
"connection_info": {},
}
],
edit_record={
"device_id": XSS_PROBE,
"name": XSS_PROBE,
"driver_type": XSS_PROBE,
"connection_info": {},
},
connection_info_json=XSS_PROBE,
error=XSS_PROBE,
)
if template == "history":
return make_history_context(
session,
entries=[
{
"occurred_at": XSS_PROBE,
"kind": XSS_PROBE,
"summary": XSS_PROBE,
}
],
)
if template == "tasks_list":
return make_tasks_list_context(
session,
tasks=[
{
"id": XSS_PROBE,
"status": XSS_PROBE,
"device_id": XSS_PROBE,
"created_at": XSS_PROBE,
"updated_at": XSS_PROBE,
}
],
)
if template == "task_detail":
return make_task_detail_context(
session,
task={
"id": XSS_PROBE,
"goal": XSS_PROBE,
"device_id": XSS_PROBE,
"status": XSS_PROBE,
"created_at": XSS_PROBE,
"updated_at": XSS_PROBE,
},
task_rows=[
("id", XSS_PROBE),
("goal", XSS_PROBE),
("status", XSS_PROBE),
],
timeline_steps=[
{
"index": 0,
"timestamp": XSS_PROBE,
"prompt": XSS_PROBE,
"tool_call_text": XSS_PROBE,
"result_text": XSS_PROBE,
"screenshot_src": None,
}
],
)
raise ValueError(f"unknown template {template}")
@pytest.mark.parametrize("template", _TEMPLATES_WITH_XSS)
def test_xss_probe_is_escaped(env, sample_session, template: str) -> None:
html = env.get_template(f"{template}.html").render(
**_xss_context(template, sample_session)
)
assert _ESCAPED_PROBE in html, f"escaped probe missing from {template}"
assert XSS_PROBE not in html, f"raw <script> leaked in {template}"
# ---------------------------------------------------------------------------
# 6.5 Byte-identity test for dashboard inline <script>
# ---------------------------------------------------------------------------
def test_dashboard_script_byte_identity(env, sample_session) -> None:
config = HostAgentConfig(control_plane_url="http://localhost:8080")
html = env.get_template("dashboard.html").render(
title="Status",
session=sample_session,
identity=None,
devices=[],
config=config,
heartbeat_text="never",
assignment_text="none",
progress_text="",
policy_text="no Cloud policy cached",
)
start = html.index("<script>")
end = html.index("</script>", start) + len("</script>")
script_block = html[start:end]
baseline = (
(Path(__file__).parent / "__baseline__" / "dashboard_script.txt")
.read_text(encoding="utf-8")
.rstrip()
)
assert script_block.rstrip() == baseline
# ---------------------------------------------------------------------------
# 6.6 No-autoescape-bypass test
# ---------------------------------------------------------------------------
def test_no_autoescape_bypass_in_templates() -> None:
templates_dir = (
Path(__file__).resolve().parents[3] / "host_agent" / "web" / "templates"
)
forbidden = ["| safe", "{% autoescape false %}", "{% endautoescape %}"]
for tpl_path in templates_dir.glob("*.html"):
content = tpl_path.read_text(encoding="utf-8")
for pattern in forbidden:
assert pattern not in content, f"{pattern} found in {tpl_path.name}"
# ---------------------------------------------------------------------------
# 6.7 _ENV autoescape configuration test
# ---------------------------------------------------------------------------
def test_env_autoescape_enabled_for_html(env) -> None:
"""The Environment's autoescape policy must escape content rendered
through .html templates."""
# Verify the autoescape policy is active (truthy or callable).
assert env.autoescape is True or callable(env.autoescape)
# Render the XSS probe through a .html template that interpolates it
# (login.html interpolates {{ error }}) and confirm it is escaped.
result = env.get_template("login.html").render(
title="Login",
session=None,
account={"username": "operator"},
error="<script>alert(1)</script>",
)
assert "&lt;script&gt;alert(1)&lt;/script&gt;" in result
assert "<script>alert(1)</script>" not in result
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-14
@@ -0,0 +1,167 @@
## 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`:
```jinja
<!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:
```python
@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.
@@ -0,0 +1,38 @@
## Why
The Host Agent local console (`apps/device-host-agent/host_agent/web/app.py`) currently renders all of its pages with f-strings plus a hand-written `escape()` helper called on every interpolated value. Five pages are already in place (login, status dashboard, devices, account, history) — including a dashboard page with an inline `<script>` polling block that has to double-brace every JS `{`/`}` to survive f-string parsing. The pending `task-execution-progress-visibility` change will add three more pages (task list, task detail, timeline) that render externally-influenced text (task summaries, scene/element descriptions) inside loops, exactly the shape f-string rendering handles worst.
The original `host-agent-local-console` design decision to skip a templating engine was made when the assumed page count was four and the rendered fields were mostly internal enums. Both assumptions are now stale: every operator-controlled field (`name`, `driver_type`, `connection_info`, upcoming task summaries) is one missed `escape()` away from an XSS sink, and the per-page f-string HTML has grown verbose enough that the `_chrome()` shell, the per-page body functions, and the inline `_CSS` constant are reproducing a templating engine by hand. Jinja2 with `autoescape=select_autoescape(["html"])` removes the "did I remember to escape this one?" class of bug by mechanism rather than by discipline, and lets loops/conditionals/inheritance be expressed natively.
## What Changes
- Add `jinja2` as an explicit direct dependency of `device-host-agent` in `apps/device-host-agent/pyproject.toml` (single pure-Python wheel, no C extension).
- Introduce a module-level Jinja2 `Environment` in `host_agent/web/app.py` configured with `autoescape=select_autoescape(["html"])` and a `FileSystemLoader` rooted at a new `host_agent/web/templates/` directory.
- Replace the hand-written `_chrome()` shell, the `_CSS` constant, and the five `_login_page`/`_dashboard_body`/`_devices_body`/`_account_body`/`_history_body` f-string builders with a `base.html` template (header/nav/CSS, `{% block body %}`) plus one template file per page.
- Migrate the inline dashboard `<script>` block into its template using `{% raw %}...{% endraw %}` so JS braces are no longer f-string-escaped; behavior of the 5-second `/api/status` polling is preserved exactly.
- Render every route handler through `TemplateResponse` instead of `HTMLResponse(_chrome(...))`. Route paths, auth/session/CSRF semantics, form fields, redirects, and the `/api/status` JSON endpoint are unchanged.
- Remove the module-level `escape()` helper and `_chrome()` from `host_agent/web/app.py` (Jinja2's autoescape subsumes both).
- Add a `tests/host_agent/web/` test module covering: each template renders without errors for representative contexts; an XSS probe (a model field set to `<script>alert(1)</script>`) appears HTML-escaped in the rendered output; the dashboard polling script body survives the `{% raw %}` migration byte-for-byte against the current page.
Non-Goals:
- No new pages, no new routes, no new visual design — this is a pure rendering-engine swap.
- No change to the `console/` SPA (Runtime API UI) or `cloud-console/` SPA, both of which keep their own Vue3 + Vite toolchains.
- No change to `host_agent/web/auth.py` (session/CSRF) or to the `host-agent-local-console` capability's auth/CSRF/session/config requirements.
- No introduction of a static-asset pipeline (no `starlette.staticfiles`, no JS/CSS bundling) — the existing inline `<style>` becomes a `{% block styles %}` in `base.html`; future work can extract it later.
## Capabilities
### New Capabilities
- `host-agent-console-template-rendering`: Host Agent local console pages SHALL be rendered through a template engine with automatic HTML escaping enabled, so that every operator- or externally-influenced field interpolated into a page is escaped by mechanism rather than by per-call discipline. Decouples the autoescape security property from any single page's author and applies uniformly to every current and future page rendered by `host_agent/web/app.py` (login, dashboard, devices, account, history, and any later additions such as the task pages planned by `task-execution-progress-visibility`).
### Modified Capabilities
(none — the existing `host-agent-local-console` capability is not yet archived into `openspec/specs/`; rather than write a MODIFIED delta against an unarchived pending spec, this change captures the autoescape requirement as a new sibling capability. The `host-agent-local-console` change's design.md decision titled "Reuse FastAPI + Starlette's `HTMLResponse`, not a new micro-framework, not Jinja2" is hereby superseded for the rendering-mechanism question only; that change's auth/session/CSRF/config/lifecycle decisions remain in force. The supersession is recorded as an Open Question in this change's design.md to be reconciled when `host-agent-local-console` is archived.)
## Impact
- Affected code: `apps/device-host-agent/pyproject.toml` (new `jinja2` direct dependency), `apps/device-host-agent/host_agent/web/app.py` (rewritten to use Jinja2 `TemplateResponse`), new `apps/device-host-agent/host_agent/web/templates/` directory (`base.html`, `login.html`, `dashboard.html`, `devices.html`, `account.html`, `history.html`), new `apps/device-host-agent/tests/host_agent/web/` test module.
- Dependency graph: `jinja2` enters the resolved `uv.lock` as a direct dependency of `device-host-agent`. It is not currently a transitive dependency of fastapi/starlette (their `Jinja2Templates` is an optional extra), so this is a genuine new dependency, not surfacing an already-present one.
- Not affected: `cloud.*`, `apps/cloud-api`, `cloud-console/`, `console/`, `host-agent-protocol` outbound behavior, `host_agent/web/auth.py`, `HostAgentConfig`, all store modules, all lifecycle/startup wiring.
- Coordination point: `openspec/changes/task-execution-progress-visibility` (in flight) currently commits in its design.md D3 to "reuse `api/console.py`'s query patterns and f-string + `html.escape()` convention" for its new Host-Agent-side task pages. After this change lands, those new pages SHOULD be written directly in Jinja2 instead; the two changes do not conflict at the code level (different files inside `host_agent/web/`), but D3's wording will need a follow-up edit when that change is next updated. This is recorded as an Open Question in design.md.
- Operational impact: none at runtime from an operator's perspective — same URLs, same login, same pages, same polling; first render of each template pays a one-time compile cost (sub-millisecond per template, cached on the module-level `Environment`).
@@ -0,0 +1,49 @@
## ADDED Requirements
### Requirement: Console HTML pages SHALL be rendered through a template engine with automatic HTML escaping enabled
The Host Agent local console SHALL render every HTML page through a single, process-wide template engine configured so that values interpolated into templates whose names end in `.html` are HTML-escaped by the engine before being written into the response, independent of whether the calling code remembers to escape them. The template engine and its autoescape configuration SHALL be constructed once at module import time of `host_agent/web/app.py` and reused by every page handler; pages SHALL NOT bypass the engine by constructing HTML through string concatenation, f-string interpolation, or any other path that skips autoescape.
#### Scenario: All current pages go through the autoescaping engine
- **WHEN** the Host Agent renders any of its login, status dashboard, devices, account, or history pages
- **THEN** the rendered HTML is produced by the shared template engine with autoescape enabled for `.html` templates, and the page handler does not construct any HTML fragment via f-string, string concatenation, or `str.format`
#### Scenario: A new page added later inherits autoescape without extra wiring
- **WHEN** a future change adds a new `.html` template under the Host Agent console's templates directory and a route handler renders it through the shared template engine
- **THEN** that new page's interpolated values are HTML-escaped by the engine without the new change having to reconfigure autoescape or wrap any value in an escaping helper
#### Scenario: Handler code returns a plain HTMLResponse built from the engine's output
- **WHEN** a console route handler produces its response
- **THEN** the response is an `HTMLResponse` whose `content` is the string returned by the template engine's `render(...)` call, and the handler does not post-process that string in a way that could re-introduce unescaped markup
### Requirement: Operator- and externally-influenced values rendered into console pages SHALL be HTML-escaped
Any value that originates from operator input (e.g. device name, driver type, connection info JSON, account username), from the control plane (e.g. host policy fields, assignment summaries), or from task execution (e.g. step summaries, scene/element descriptions) SHALL be HTML-escaped by the template engine when interpolated into a console page, so that the rendered output contains no executable `<script>` markup, event-handler attributes, or other HTML capable of executing in the browser session of an authenticated console operator.
#### Scenario: A device name containing an XSS probe is rendered escaped
- **WHEN** an operator registers a local device whose name contains the string `<script>alert(1)</script>` and the operator subsequently loads the devices page or the status dashboard
- **THEN** the rendered HTML contains the literal text `&lt;script&gt;alert(1)&lt;/script&gt;` (or an equivalent escaped form) in the position where the device name is interpolated, and contains no `<script>` element corresponding to the device name
#### Scenario: A driver connection_info JSON containing an XSS probe is rendered escaped
- **WHEN** a device's `connection_info` JSON value contains a string with the characters `<`, `>`, `"`, or `'` in any position, and the devices page renders that value (for example inside a `<textarea>` edit field)
- **THEN** the rendered HTML escapes those characters so the value round-trips back to the original JSON when the form is submitted unchanged, and the page contains no executable markup introduced by the connection_info value
#### Scenario: An assignment or step summary containing an XSS probe is rendered escaped
- **WHEN** a task summary, step status, scene description, or any other externally-influenced string containing `<script>` markup is rendered on any console page (including any future task list, task detail, or timeline page added by a later change)
- **THEN** the rendered HTML escapes the markup and contains no executable `<script>` element corresponding to that string
### Requirement: Disabling autoescape for a console template SHALL require an explicit, in-template opt-in
The console's template engine SHALL NOT be configured with autoescape disabled globally. A template that needs to interpolate trusted, pre-built HTML (for example a fragment assembled by Python code from already-escaped parts) SHALL opt out of autoescape only for the specific region and only by an explicit in-template construct (such as Jinja2's `{% autoescape false %}...{% endautoescape %}` block or the `| safe` filter applied to a specific expression). No current console page requires such an opt-out.
#### Scenario: Global autoescape is on
- **WHEN** the console's template engine is constructed at module import time
- **THEN** its configuration enables autoescape for every template whose name ends in `.html`, and no process-wide setting, environment variable, or configuration field disables that behavior
#### Scenario: A future page that needs trusted-HTML interpolation scopes the opt-out locally
- **WHEN** a later change adds a console template that interpolates a fragment the change has already assembled and escaped on the Python side, and that change wishes to avoid double-escaping
- **THEN** the opt-out is expressed as an explicit, template-local construct around the specific interpolation, and does not disable autoescape for the rest of the template or for any other template
### Requirement: The console's template-rendering configuration SHALL be observable in tests
The console's module-level template engine SHALL be exposed in a way that lets automated tests render any console template with a caller-supplied context and assert on the resulting HTML, so that the autoescape behavior of every current and future page can be verified by a regression test without spinning up the full Host Agent HTTP server.
#### Scenario: A test renders a template with a malicious context
- **WHEN** an automated test calls the console's template engine with a template name and a context that includes a field whose value is `<script>alert(1)</script>`
- **THEN** the test receives the rendered HTML as a string and can assert that the XSS probe has been HTML-escaped in the output
@@ -0,0 +1,71 @@
## 1. Dependency and lockfile
- [x] 1.1 Add `jinja2>=3.1` to the `dependencies` list in `apps/device-host-agent/pyproject.toml`
- [x] 1.2 Run `uv lock` (or `uv lock --package device-host-agent`) and confirm `jinja2` plus its sole transitive runtime dependency `markupsafe` resolve; commit the updated `uv.lock`
- [x] 1.3 Run `uv sync --locked --all-packages` and confirm no package fails to install
## 2. Capture pre-migration reference
- [x] 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 (mock `SessionState`, mock `HostIdentityState`, two sample devices, one history entry) and save the HTML to `apps/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
- [x] 2.2 Note the exact bytes of the dashboard inline `<script>` block (from `function render(data) {` through the closing `})();`) into a separate `dashboard_script.txt` reference under the same baseline directory, for the byte-identical assertion in 6.4
## 3. Templates
- [x] 3.1 Create `apps/device-host-agent/host_agent/web/templates/base.html` per design D4: `<!doctype html>`, `<head>` with `<meta charset="utf-8">`, `<title>{{ title }}</title>`, a `<style>` block holding the current `_CSS` content, a `<header>` containing `<strong>Host Agent Console</strong>` and a `{% block nav %}` that renders the same nav (Status / Devices / Account / History / Logout form) when `session` is truthy and empty otherwise, and a `<main>` containing `{% block body %}{% endblock %}`
- [x] 3.2 Create `templates/login.html` extending `base.html`, overriding `{% block nav %}` to empty and `{% block body %}` with the current `_login_page` body markup, including the "no local account exists" branch (use `{% if not account %}`) and the optional error paragraph (`{% if error %}`)
- [x] 3.3 Create `templates/dashboard.html` extending `base.html`, porting the current `_dashboard_body` body 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
- [x] 3.4 Create `templates/devices.html` extending `base.html`, porting the current `_devices_body` markup 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)
- [x] 3.5 Create `templates/account.html` extending `base.html`, porting the current `_account_body` markup including the optional message/error paragraphs and the change-password form
- [x] 3.6 Create `templates/history.html` extending `base.html`, porting the current `_history_body` markup including the history table with a `{% for entry in entries %}` loop
## 4. Engine and route handlers
- [x] 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"]))`; import `jinja2` and `pathlib.Path` at module top
- [x] 4.2 Rewrite the `/login` GET handler to render `login.html` via `_ENV.get_template("login.html").render(account=account)` and return `HTMLResponse(...)`; keep the current `asyncio.to_thread(local_account_store.load)` call shape
- [x] 4.3 Rewrite the `/` dashboard GET handler to render `dashboard.html` with `identity`, `snapshot`, `devices`, `config`, `session` in the context; ensure the inline `<script>` survives `{% raw %}` migration byte-identically (visually diff against the 2.2 reference)
- [x] 4.4 Rewrite the `/devices` GET handler to render `devices.html` with `devices`, `csrf_token`, `edit_record`, `error=None`; keep the existing `config_store.list`/`config_store.get` calls
- [x] 4.5 Rewrite the `/devices/save` POST handler's error-path branch to render `devices.html` (same context as 4.4 with `error=<message>`); keep the success-path `RedirectResponse(url="/devices", status_code=303)` unchanged
- [x] 4.6 Rewrite the `/account` GET and POST handlers to render `account.html` with `csrf_token`, `message`, `error` in the context; preserve the password-change success/error branches
- [x] 4.7 Rewrite the `/history` GET handler to render `history.html` with `entries` in the context
- [x] 4.8 Confirm `/api/status` (JSON) and all POST handlers that issue `RedirectResponse` (`/login`, `/logout`, `/devices/save` success, `/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
- [x] 5.1 Remove the `escape()` helper from `host_agent/web/app.py`
- [x] 5.2 Remove `_chrome()` from `host_agent/web/app.py`
- [x] 5.3 Remove the `_CSS` constant from `host_agent/web/app.py`
- [x] 5.4 Remove the `_login_page`, `_dashboard_body`, `_devices_body`, `_account_body`, `_history_body` functions from `host_agent/web/app.py`
- [x] 5.5 Remove the now-unused `from html import escape as _escape` import
- [x] 5.6 Grep `apps/device-host-agent/` for any remaining `escape(`, `_chrome(`, `_CSS`, `_dashboard_body`, `_devices_body`, `_account_body`, `_history_body`, `_login_page` references and remove any stragglers; only `_ENV` / `get_template` / `render` references should remain in `host_agent/web/app.py`
## 6. Tests
- [x] 6.1 Create `apps/device-host-agent/tests/host_agent/web/__init__.py` if not present (empty)
- [x] 6.2 Create `apps/device-host-agent/tests/host_agent/web/conftest.py` with fixtures exposing the module-level `_ENV` from `host_agent.web.app` and 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)
- [x] 6.3 Create `apps/device-host-agent/tests/host_agent/web/test_templates.py` with 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
- [x] 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 `&lt;script&gt;alert(1)&lt;/script&gt;` does
- [x] 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 of `tests/host_agent/web/__baseline__/dashboard_script.txt` captured in 2.2 (normalize trailing whitespace)
- [x] 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)
- [x] 6.7 Add a test asserting `_ENV.autoescape` is configured for `.html` templates (e.g. by rendering a template whose name ends in `.html` with a probe value and confirming the output is escaped — concrete and future-proof against Environment construction changes)
## 7. Local validation
- [x] 7.1 Run `uv run --package device-host-agent pytest` and confirm the full package suite (existing tests plus the new section-6 tests) passes
- [x] 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
- [x] 7.3 Run `ruff check --fix` and `ruff format` over `apps/device-host-agent/host_agent/web/`, `apps/device-host-agent/tests/host_agent/web/`, and `apps/device-host-agent/pyproject.toml`; resolve any findings
- [x] 7.4 Run `python -m compileall apps/device-host-agent/host_agent/web apps/device-host-agent/tests/host_agent/web` and confirm no syntax errors
- [x] 7.5 Run `git diff --check` to catch whitespace errors before commit
- [x] 7.6 Run `openspec validate host-agent-console-jinja2-templates --strict` and confirm it passes
## 8. Manual browser verification
- [ ] 8.1 Start a Host Agent locally (`uv run --package device-host-agent device-host-agent` or the existing run command documented in `docs/CLOUD_DEPLOYMENT.md`); open `http://127.0.0.1:8765/login` in 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 `/history` and confirm recent entries render
- [ ] 8.6 Compare each page's visual layout to the pre-change version and confirm no styling regression (the `_CSS` content must be byte-identical inside `base.html`'s `<style>` block)
## 9. Coordination follow-up (not blocking archive of this change)
- [x] 9.1 After this change lands on `master`, open a follow-up note (issue or PR comment) on `openspec/changes/task-execution-progress-visibility` advising that its design.md D3 ("reuse `api/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.
Generated
+14
View File
@@ -570,6 +570,7 @@ dependencies = [
{ name = "device-cloud-platform" },
{ name = "fastapi" },
{ name = "httpx" },
{ name = "jinja2" },
{ name = "uvicorn", extra = ["standard"] },
]
@@ -579,6 +580,7 @@ requires-dist = [
{ name = "device-cloud-platform", editable = "packages/cloud-platform" },
{ name = "fastapi", specifier = ">=0.115.0" },
{ name = "httpx", specifier = ">=0.27.0" },
{ name = "jinja2", specifier = ">=3.1" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" },
]
@@ -862,6 +864,18 @@ wheels = [
{ url = "https://mirrors.aliyun.com/pypi/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://mirrors.aliyun.com/pypi/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d" }
wheels = [
{ url = "https://mirrors.aliyun.com/pypi/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67" },
]
[[package]]
name = "jiter"
version = "0.16.0"