feat(host-agent): migrate local console to Jinja2 templates with autoescape
Tests / Test failed: 4, passed: 744
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:
@@ -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> — {{ 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 %}
|
||||
Reference in New Issue
Block a user