diff --git a/apps/device-host-agent/host_agent/web/app.py b/apps/device-host-agent/host_agent/web/app.py
index 05ebded..4a3eaf7 100644
--- a/apps/device-host-agent/host_agent/web/app.py
+++ b/apps/device-host-agent/host_agent/web/app.py
@@ -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"""
-
- """
- return f"""
-
-
-
-{escape(title)}
-
-
-
-
- Host Agent Console
- {nav}
-
-
-{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 = """
- Login
- No local account exists yet. Run device-host-agent setup
- on this machine to create one before logging in to the console.
- """
- return HTMLResponse(_chrome("Login", body, session=None))
- error_html = f'{escape(error)}
' if error else ""
- body = f"""
- Login
- {error_html}
-
- """
- 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"| {escape(device.id)} | {escape(device.name or '')} | "
- f"{escape(device.driver_type)} | "
- f"{escape(_device_display_status(device, busy_device_id=busy_device_id))} |
"
- for device in devices
- )
- return f"""
- Status
-
- Enrollment
- Host ID: {escape(identity.host_id if identity else None) or "not enrolled"}
- Agent instance ID: {escape(identity.agent_instance_id if identity else None) or "unknown"}
- Control plane: {escape(config.control_plane_url)}
-
-
- Heartbeat
- {escape(heartbeat_text)}
-
-
- Cloud policy
- {escape(policy_text)}
-
-
- Current assignment
- {escape(assignment_text)}
- {escape(progress_text)}
-
-
- Devices
-
- | ID | Name | Driver | Status |
- {device_rows}
-
-
-
- """
-
-
-def _devices_body(
- *,
- devices: list[dict[str, Any]],
- csrf_token: str,
- edit_record: dict[str, Any] | None,
- error: str | None,
-) -> str:
- error_html = f'{escape(error)}
' if error else ""
- rows = "".join(
- f"""
-
- | {escape(device["device_id"])} |
- {escape(device["name"] or "")} |
- {escape(device["driver_type"])} |
- {escape(device["cloud_device_id"] or "")} |
-
- Edit
-
- |
-
- """
- 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"""
- Devices
- {error_html}
-
- | ID | Name | Driver | Cloud ID | |
- {rows}
-
- {"Edit device" if edit_record else "Add device"}
-
- """
-
-
-def _account_body(*, csrf_token: str, message: str | None, error: str | None) -> str:
- message_html = f'{escape(message)}
' if message else ""
- error_html = f'{escape(error)}
' if error else ""
- return f"""
- Account
- {message_html}
- {error_html}
-
- """
-
-
-def _history_body(entries: list[dict[str, Any]]) -> str:
- rows = "".join(
- f"| {escape(entry['occurred_at'])} | {escape(entry['kind'])} | "
- f"{escape(entry['summary'])} |
"
- for entry in entries
- )
- return f"""
- History
-
- | Time | Kind | Summary |
- {rows}
-
- """
-
-
-def _inline_screenshot(record: dict[str, Any]) -> str:
- """Return a ``
`` 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'
'
-
-
-def _tasks_body(tasks: list[dict[str, Any]]) -> str:
- if not tasks:
- return """
- Tasks
- No tasks recorded.
- """
- rows = "".join(
- f""
- f'| {escape(task["id"])} | '
- f"{escape(task.get('status') or '')} | "
- f"{escape(task.get('device_id') or '')} | "
- f"{escape(task.get('created_at') or '')} | "
- f"{escape(task.get('updated_at') or '')} | "
- f"
"
- for task in tasks
- )
- return f"""
- Tasks
-
- | Task ID | Status | Device | Created | Updated |
- {rows}
-
- """
-
-
-def _task_detail_body(
- task: dict[str, Any], timeline_records: list[dict[str, Any]]
-) -> str:
- task_rows = "".join(
- f"| {escape(key)} | {escape(task[key])} |
"
- for key in ("id", "goal", "device_id", "status", "created_at", "updated_at")
- if task.get(key) is not None
- )
- metadata_html = f"""
- Task {escape(task.get("id") or "")}
-
- | Field | Value |
- {task_rows}
-
- """
- if not timeline_records:
- timeline_html = "Timeline
No timeline records.
"
- else:
- step_blocks = "".join(
- _timeline_step_html(record) for record in timeline_records
- )
- timeline_html = f"Timeline
{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"""
-
-
Step {escape(index)} — {escape(timestamp)}
-
Prompt: {escape(prompt)}
-
Tool call: {escape(tool_call_text)}
-
Result: {escape(result_text)}
- {screenshot_html}
-
- """
+ 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
diff --git a/apps/device-host-agent/host_agent/web/templates/account.html b/apps/device-host-agent/host_agent/web/templates/account.html
new file mode 100644
index 0000000..5259d49
--- /dev/null
+++ b/apps/device-host-agent/host_agent/web/templates/account.html
@@ -0,0 +1,17 @@
+{% extends "base.html" %}
+{% block body %}
+ Account
+ {% if message %}
+ {{ message }}
+ {% endif %}
+ {% if error %}
+ {{ error }}
+ {% endif %}
+
+{% endblock %}
diff --git a/apps/device-host-agent/host_agent/web/templates/base.html b/apps/device-host-agent/host_agent/web/templates/base.html
new file mode 100644
index 0000000..89facd6
--- /dev/null
+++ b/apps/device-host-agent/host_agent/web/templates/base.html
@@ -0,0 +1,40 @@
+
+
+
+
+{{ title }}
+
+
+
+
+
+{% block body %}{% endblock %}
+
+
+
diff --git a/apps/device-host-agent/host_agent/web/templates/dashboard.html b/apps/device-host-agent/host_agent/web/templates/dashboard.html
new file mode 100644
index 0000000..8108d3c
--- /dev/null
+++ b/apps/device-host-agent/host_agent/web/templates/dashboard.html
@@ -0,0 +1,73 @@
+{% extends "base.html" %}
+{% block body %}
+ Status
+
+ Enrollment
+ Host ID: {{ identity.host_id if identity else "" or "not enrolled" }}
+ Agent instance ID: {{ identity.agent_instance_id if identity else "" or "unknown" }}
+ Control plane: {{ config.control_plane_url }}
+
+
+ Heartbeat
+ {{ heartbeat_text }}
+
+
+ Cloud policy
+ {{ policy_text }}
+
+
+ Current assignment
+ {{ assignment_text }}
+ {{ progress_text }}
+
+
+ Devices
+
+ | ID | Name | Driver | Status |
+ {% for device in devices %}| {{ device.id }} | {{ device.name or "" }} | {{ device.driver_type }} | {{ device.display_status }} |
{% endfor %}
+
+
+ {% raw %}{% endraw %}
+{% endblock %}
diff --git a/apps/device-host-agent/host_agent/web/templates/devices.html b/apps/device-host-agent/host_agent/web/templates/devices.html
new file mode 100644
index 0000000..10b8929
--- /dev/null
+++ b/apps/device-host-agent/host_agent/web/templates/devices.html
@@ -0,0 +1,37 @@
+{% extends "base.html" %}
+{% block body %}
+ Devices
+ {% if error %}
+ {{ error }}
+ {% endif %}
+
+ | ID | Name | Driver | Cloud ID | |
+ {% for device in devices %}
+
+ | {{ device["device_id"] }} |
+ {{ device["name"] or "" }} |
+ {{ device["driver_type"] }} |
+ {{ device["cloud_device_id"] or "" }} |
+
+ Edit
+
+ |
+
+ {% endfor %}
+
+ {{ "Edit device" if edit_record else "Add device" }}
+
+{% endblock %}
diff --git a/apps/device-host-agent/host_agent/web/templates/history.html b/apps/device-host-agent/host_agent/web/templates/history.html
new file mode 100644
index 0000000..37a98b1
--- /dev/null
+++ b/apps/device-host-agent/host_agent/web/templates/history.html
@@ -0,0 +1,8 @@
+{% extends "base.html" %}
+{% block body %}
+ History
+
+ | Time | Kind | Summary |
+ {% for entry in entries %}| {{ entry["occurred_at"] }} | {{ entry["kind"] }} | {{ entry["summary"] }} |
{% endfor %}
+
+{% endblock %}
diff --git a/apps/device-host-agent/host_agent/web/templates/login.html b/apps/device-host-agent/host_agent/web/templates/login.html
new file mode 100644
index 0000000..4d75042
--- /dev/null
+++ b/apps/device-host-agent/host_agent/web/templates/login.html
@@ -0,0 +1,18 @@
+{% extends "base.html" %}
+{% block nav %}{% endblock %}
+{% block body %}
+ Login
+ {% if not account %}
+ No local account exists yet. Run device-host-agent setup
+ on this machine to create one before logging in to the console.
+ {% else %}
+ {% if error %}
+ {{ error }}
+ {% endif %}
+
+ {% endif %}
+{% endblock %}
diff --git a/apps/device-host-agent/host_agent/web/templates/task_detail.html b/apps/device-host-agent/host_agent/web/templates/task_detail.html
new file mode 100644
index 0000000..2ba3825
--- /dev/null
+++ b/apps/device-host-agent/host_agent/web/templates/task_detail.html
@@ -0,0 +1,22 @@
+{% extends "base.html" %}
+{% block body %}
+ Task {{ task.get("id") or "" }}
+
+ | Field | Value |
+ {% for row in task_rows %}| {{ row[0] }} | {{ row[1] }} |
{% endfor %}
+
+ Timeline
+ {% if not timeline_steps %}
+ No timeline records.
+ {% else %}
+ {% for step in timeline_steps %}
+
+
Step {{ step.index }} — {{ step.timestamp }}
+
Prompt: {{ step.prompt }}
+
Tool call: {{ step.tool_call_text }}
+
Result: {{ step.result_text }}
+ {% if step.screenshot_src %}

{% endif %}
+
+ {% endfor %}
+ {% endif %}
+{% endblock %}
diff --git a/apps/device-host-agent/host_agent/web/templates/tasks_list.html b/apps/device-host-agent/host_agent/web/templates/tasks_list.html
new file mode 100644
index 0000000..b0b7f7f
--- /dev/null
+++ b/apps/device-host-agent/host_agent/web/templates/tasks_list.html
@@ -0,0 +1,20 @@
+{% extends "base.html" %}
+{% block body %}
+ Tasks
+ {% if not tasks %}
+ No tasks recorded.
+ {% else %}
+
+ | Task ID | Status | Device | Created | Updated |
+ {% for task in tasks %}
+
+ | {{ task["id"] }} |
+ {{ task.get("status") or "" }} |
+ {{ task.get("device_id") or "" }} |
+ {{ task.get("created_at") or "" }} |
+ {{ task.get("updated_at") or "" }} |
+
+ {% endfor %}
+
+ {% endif %}
+{% endblock %}
diff --git a/apps/device-host-agent/pyproject.toml b/apps/device-host-agent/pyproject.toml
index 7f32b8f..7299377 100644
--- a/apps/device-host-agent/pyproject.toml
+++ b/apps/device-host-agent/pyproject.toml
@@ -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 }
diff --git a/apps/device-host-agent/tests/host_agent/web/__baseline__/dashboard_script.txt b/apps/device-host-agent/tests/host_agent/web/__baseline__/dashboard_script.txt
new file mode 100644
index 0000000..9f135d8
--- /dev/null
+++ b/apps/device-host-agent/tests/host_agent/web/__baseline__/dashboard_script.txt
@@ -0,0 +1,43 @@
+
diff --git a/apps/device-host-agent/tests/host_agent/web/__init__.py b/apps/device-host-agent/tests/host_agent/web/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/apps/device-host-agent/tests/host_agent/web/conftest.py b/apps/device-host-agent/tests/host_agent/web/conftest.py
new file mode 100644
index 0000000..3a2b2b5
--- /dev/null
+++ b/apps/device-host-agent/tests/host_agent/web/conftest.py
@@ -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 = ""
+_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,
+ }
diff --git a/apps/device-host-agent/tests/host_agent/web/test_templates.py b/apps/device-host-agent/tests/host_agent/web/test_templates.py
new file mode 100644
index 0000000..28715bb
--- /dev/null
+++ b/apps/device-host-agent/tests/host_agent/web/test_templates.py
@@ -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 '