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

- - - {device_rows} -
IDNameDriverStatus
-
- - """ - - -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} - - - {rows} -
IDNameDriverCloud ID
-

{"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

- - - {rows} -
TimeKindSummary
- """ - - -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'screenshot' - - -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

- - - {rows} -
Task IDStatusDeviceCreatedUpdated
- """ - - -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 "")}

- - - {task_rows} -
FieldValue
- """ - 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 }} + + + +
+ Host Agent Console + {% block nav %}{% if session %} + + {% endif %}{% endblock %} +
+
+{% 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

+ + + {% for device in devices %}{% endfor %} +
IDNameDriverStatus
{{ device.id }}{{ device.name or "" }}{{ device.driver_type }}{{ device.display_status }}
+
+ {% 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 %} + + + {% for device in devices %} + + + + + + + + {% endfor %} +
IDNameDriverCloud ID
{{ device["device_id"] }}{{ device["name"] or "" }}{{ device["driver_type"] }}{{ device["cloud_device_id"] or "" }} + Edit +
+ + + +
+
+

{{ "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

+ + + {% for entry in entries %}{% endfor %} +
TimeKindSummary
{{ entry["occurred_at"] }}{{ entry["kind"] }}{{ entry["summary"] }}
+{% 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 "" }}

+ + + {% for row in task_rows %}{% endfor %} +
FieldValue
{{ row[0] }}{{ row[1] }}
+

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 %}screenshot{% 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 %} + + + {% for task in tasks %} + + + + + + + + {% endfor %} +
Task IDStatusDeviceCreatedUpdated
{{ task["id"] }}{{ task.get("status") or "" }}{{ task.get("device_id") or "" }}{{ task.get("created_at") or "" }}{{ task.get("updated_at") or "" }}
+ {% 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 '
' 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 '' in html + + +def test_account_renders(env, sample_session) -> None: + html = env.get_template("account.html").render( + **make_account_context(sample_session) + ) + assert '' in html + + +def test_history_renders(env, sample_session) -> None: + html = env.get_template("history.html").render( + **make_history_context(sample_session) + ) + assert "" 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 "

Tasks

" 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 "

Timeline

" in html + + +# --------------------------------------------------------------------------- +# 6.4 XSS-probe tests (parametrised over templates with operator-influenced +# string fields set to ) +# --------------------------------------------------------------------------- + +_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 ", start) + len("") + 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="", + ) + assert "<script>alert(1)</script>" in result + assert "" not in result diff --git a/openspec/changes/host-agent-console-jinja2-templates/.openspec.yaml b/openspec/changes/host-agent-console-jinja2-templates/.openspec.yaml new file mode 100644 index 0000000..64105fc --- /dev/null +++ b/openspec/changes/host-agent-console-jinja2-templates/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-14 diff --git a/openspec/changes/host-agent-console-jinja2-templates/design.md b/openspec/changes/host-agent-console-jinja2-templates/design.md new file mode 100644 index 0000000..6d62457 --- /dev/null +++ b/openspec/changes/host-agent-console-jinja2-templates/design.md @@ -0,0 +1,167 @@ +## Context + +`apps/device-host-agent/host_agent/web/app.py` currently renders five HTML pages (login, status dashboard, devices, account, history) by composing f-string templates with a module-level `escape()` helper (a thin wrapper over `html.escape(..., quote=True)`). A shared `_chrome(title, body_html, session)` function acts as a hand-written base template; a `_CSS` string constant holds the inline stylesheet; the dashboard page embeds a `` into every operator-controlled field and asserts the escaped form appears in the output. +- **[Trade-off] The dashboard's ``) appears HTML-escaped in the rendered output; the dashboard polling script body survives the `{% raw %}` migration byte-for-byte against the current page. + +Non-Goals: + +- No new pages, no new routes, no new visual design — this is a pure rendering-engine swap. +- No change to the `console/` SPA (Runtime API UI) or `cloud-console/` SPA, both of which keep their own Vue3 + Vite toolchains. +- No change to `host_agent/web/auth.py` (session/CSRF) or to the `host-agent-local-console` capability's auth/CSRF/session/config requirements. +- No introduction of a static-asset pipeline (no `starlette.staticfiles`, no JS/CSS bundling) — the existing inline `