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