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,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 = "<script>alert(1)</script>"
|
||||
|
||||
|
||||
@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,
|
||||
}
|
||||
Reference in New Issue
Block a user