from __future__ import annotations
import asyncio
import base64
import json
from html import escape as _escape
from pathlib import Path
from typing import Any
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
from device.manager import DeviceManager
from host_agent.assignment import AssignmentExecutor
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.status import AgentStatusTracker
from host_agent.web.auth import (
SessionManager,
SessionState,
attempt_login,
change_password,
)
from storage.device_config import DeviceConfigStore
from storage.task_metadata import TaskMetadataStore
from storage.timeline import Timeline
SESSION_COOKIE_NAME = "host_console_session"
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; }
"""
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"""
Status
Devices
Tasks
Account
History
"""
return f"""
{escape(title)}
{body_html}
"""
def _login_page(
*, account: LocalAccountState | None, error: str | None = None
) -> 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))
def _device_display_status(device: Any, *, busy_device_id: str | None) -> str:
"""Connected-but-idle devices report "busy" at the DeviceManager layer
(an active Appium/WDA session is required to reuse it), which is not the
same as a task currently running on that device. Only the device actually
bound to the current assignment should read as "busy" here.
"""
if device.status == "busy" and device.id != busy_device_id:
return "connected"
return device.status
def _dashboard_body(
*,
identity: HostIdentityState | None,
snapshot: dict[str, Any],
devices: list[Any],
config: HostAgentConfig,
) -> str:
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}
Current password
New password
Confirm new password
Change password
"""
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
"""
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 "")}
"""
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}
"""
def create_console_app(
*,
config: HostAgentConfig,
manager: DeviceManager,
config_store: DeviceConfigStore,
local_account_store: LocalAccountStore,
identity_store: HostIdentityStore,
history_store: ConsoleHistoryStore,
status_tracker: AgentStatusTracker,
session_manager: SessionManager,
enrollment_client: HostAgentEnrollmentClient | None,
metadata_store: TaskMetadataStore | None = None,
timeline: Timeline | None = None,
executor: AssignmentExecutor | None = None,
) -> FastAPI:
app = FastAPI(title="Host Agent Console")
cookie_secure = config.console_bind_host not in _LOOPBACK_BIND_HOSTS
def _session_token(request: Request) -> str | None:
return request.cookies.get(SESSION_COOKIE_NAME)
def require_session(request: Request) -> SessionState:
token = _session_token(request)
state = session_manager.validate(token) if token else None
if state is None:
raise HTTPException(status_code=303, headers={"Location": "/login"})
return state
async def require_csrf(
request: Request,
session: SessionState = Depends(require_session),
) -> SessionState:
provided = request.headers.get(CSRF_HEADER_NAME)
if not provided:
form = await request.form()
raw = form.get(CSRF_FORM_FIELD)
provided = str(raw) if raw is not None else None
token = _session_token(request)
if (
not token
or not provided
or not session_manager.validate_csrf(token, provided)
):
raise HTTPException(status_code=403, detail="invalid CSRF token")
return session
@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)
@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)
form = await request.form()
username = str(form.get("username", ""))
password = str(form.get("password", ""))
ok = await asyncio.to_thread(
attempt_login, local_account_store, username=username, password=password
)
if not ok:
return _login_page(account=account, error="Invalid username or password.")
session_token, _ = session_manager.create_session(username)
response = RedirectResponse(url="/", status_code=303)
response.set_cookie(
key=SESSION_COOKIE_NAME,
value=session_token,
httponly=True,
samesite="strict",
secure=cookie_secure,
path="/",
)
return response
@app.post("/logout")
async def logout(
request: Request,
session: SessionState = Depends(require_csrf),
) -> Response:
token = _session_token(request)
if token:
session_manager.invalidate(token)
response = RedirectResponse(url="/login", status_code=303)
response.delete_cookie(key=SESSION_COOKIE_NAME, path="/")
return response
@app.get("/", response_class=HTMLResponse)
async def dashboard(
session: SessionState = Depends(require_session),
) -> 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
)
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:
snapshot["progress"] = {
"step_index": live.step_index,
"step_status": live.step_status,
"summary": live.summary,
"updated_at": live.updated_at.isoformat(),
}
current_assignment = snapshot.get("current_assignment")
busy_device_id = current_assignment["device_id"] if current_assignment else None
devices = [
{
"id": device.id,
"name": device.name,
"driver_type": device.driver_type,
"status": _device_display_status(device, busy_device_id=busy_device_id),
}
for device in manager.list_devices()
]
return JSONResponse({"status": snapshot, "devices": devices})
@app.get("/devices", response_class=HTMLResponse)
async def devices_page(
request: Request,
session: SessionState = Depends(require_session),
) -> HTMLResponse:
devices = await asyncio.to_thread(config_store.list)
edit_id = request.query_params.get("edit")
edit_record = (
await asyncio.to_thread(config_store.get, edit_id) if edit_id else None
)
body = _devices_body(
devices=devices,
csrf_token=session.csrf_token,
edit_record=edit_record,
error=None,
)
return HTMLResponse(_chrome("Devices", body, session=session))
@app.post("/devices/save")
async def devices_save(
request: Request,
session: SessionState = Depends(require_csrf),
) -> Response:
form = await request.form()
device_id = str(form.get("device_id", "")).strip()
driver_type = str(form.get("driver_type", "")).strip()
name = str(form.get("name", "")).strip() or None
connection_info_raw = str(form.get("connection_info", "") or "{}")
error: str | None = None
connection_info: dict[str, Any] = {}
if not device_id or not driver_type:
error = "Device ID and driver type are required."
else:
try:
parsed = json.loads(connection_info_raw)
except ValueError:
error = "Connection info must be valid JSON."
else:
if not isinstance(parsed, dict):
error = "Connection info must be a JSON object."
else:
connection_info = parsed
if error is None:
try:
await asyncio.to_thread(
register_local_device,
config_store,
manager,
device_id=device_id,
driver_type=driver_type,
connection_info=connection_info,
name=name,
config=config,
enrollment_client=enrollment_client,
)
except ValueError as exc:
error = str(exc)
if error is not None:
devices = await asyncio.to_thread(config_store.list)
body = _devices_body(
devices=devices,
csrf_token=session.csrf_token,
edit_record=None,
error=error,
)
return HTMLResponse(
_chrome("Devices", body, session=session), status_code=400
)
return RedirectResponse(url="/devices", status_code=303)
@app.post("/devices/remove")
async def devices_remove(
request: Request,
session: SessionState = Depends(require_csrf),
) -> Response:
form = await request.form()
device_id = str(form.get("device_id", "")).strip()
if device_id:
await asyncio.to_thread(
unregister_local_device, config_store, manager, device_id=device_id
)
return RedirectResponse(url="/devices", status_code=303)
@app.get("/account", response_class=HTMLResponse)
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))
@app.post("/account", response_class=HTMLResponse)
async def account_submit(
request: Request,
session: SessionState = Depends(require_csrf),
) -> HTMLResponse:
form = await request.form()
current_password = str(form.get("current_password", ""))
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(
csrf_token=session.csrf_token,
message=None,
error="New password and confirmation must match.",
)
return HTMLResponse(
_chrome("Account", body, session=session), status_code=400
)
ok = await asyncio.to_thread(
change_password,
local_account_store,
current_password=current_password,
new_password=new_password,
)
if not ok:
body = _account_body(
csrf_token=session.csrf_token,
message=None,
error="Current password is incorrect.",
)
return HTMLResponse(
_chrome("Account", body, session=session), status_code=400
)
body = _account_body(
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))
@app.get("/tasks", response_class=HTMLResponse)
async def tasks_page(
session: SessionState = Depends(require_session),
) -> HTMLResponse:
if metadata_store is None:
raise HTTPException(
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))
@app.get("/tasks/{task_id}", response_class=HTMLResponse)
async def task_detail_page(
task_id: str,
session: SessionState = Depends(require_session),
) -> HTMLResponse:
if metadata_store is None:
raise HTTPException(
status_code=503, detail="task metadata store not configured"
)
task = await asyncio.to_thread(metadata_store.get_task, task_id)
if task is None:
raise HTTPException(status_code=404, detail="task not found")
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))
return app