feat(host-agent): add Console task submission with Host self-submission client
Adds a CSRF-protected task submission form to the local Console Tasks page
This commit is contained in:
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
from collections.abc import Awaitable, Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -12,7 +13,12 @@ from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Resp
|
||||
|
||||
from device.manager import DeviceManager
|
||||
from host_agent.assignment import AssignmentExecutor
|
||||
from host_agent.client import HostAgentEnrollmentClient
|
||||
from host_agent.client import (
|
||||
HostAgentClient,
|
||||
HostAgentEnrollmentClient,
|
||||
HostAgentAPIError,
|
||||
HostTaskSubmissionUnknownError,
|
||||
)
|
||||
from host_agent.config import HostAgentConfig
|
||||
from host_agent.devices import register_local_device, unregister_local_device
|
||||
from host_agent.history import ConsoleHistoryStore
|
||||
@@ -34,6 +40,9 @@ CSRF_HEADER_NAME = "X-CSRF-Token"
|
||||
CSRF_FORM_FIELD = "csrf_token"
|
||||
_LOOPBACK_BIND_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
|
||||
|
||||
TaskSubmissionCallable = Callable[..., Awaitable[str]]
|
||||
AUTOMATIC_DEVICE_VALUE = "__automatic__"
|
||||
|
||||
_ENV = jinja2.Environment(
|
||||
loader=jinja2.FileSystemLoader(Path(__file__).parent / "templates"),
|
||||
autoescape=jinja2.select_autoescape(["html", "xml"]),
|
||||
@@ -73,6 +82,34 @@ def _screenshot_data_uri(record: dict[str, Any]) -> str | None:
|
||||
return f"data:image/png;base64,{encoded}"
|
||||
|
||||
|
||||
def _safe_submission_error(detail: str) -> str:
|
||||
"""Return a safe, single-line error message for the operator.
|
||||
|
||||
The Cloud response ``detail`` is treated as a static control-plane message;
|
||||
we strip surrounding whitespace and reject empty results so the operator
|
||||
never sees a blank error or, through Jinja autoescape, anything that could
|
||||
carry unrendered HTML.
|
||||
"""
|
||||
cleaned = " ".join(str(detail).split()).strip()
|
||||
return cleaned or "Cloud rejected the task submission."
|
||||
|
||||
|
||||
def _extract_task_id(response: Any) -> str | None:
|
||||
"""Normalize the Host self-submission return value to a Cloud task ID.
|
||||
|
||||
Tests and narrow protocol overrides may return a bare string while the
|
||||
production client returns a pydantic model. Accept either so the rest of
|
||||
the handler can rely on a single string ID.
|
||||
"""
|
||||
candidate: Any = response
|
||||
if hasattr(candidate, "task_id"):
|
||||
candidate = getattr(candidate, "task_id")
|
||||
if not isinstance(candidate, str):
|
||||
return None
|
||||
cleaned = candidate.strip()
|
||||
return cleaned or None
|
||||
|
||||
|
||||
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")
|
||||
@@ -120,12 +157,26 @@ def create_console_app(
|
||||
status_tracker: AgentStatusTracker,
|
||||
session_manager: SessionManager,
|
||||
enrollment_client: HostAgentEnrollmentClient | None,
|
||||
host_client: HostAgentClient | None = None,
|
||||
submit_self_task: TaskSubmissionCallable | None = 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
|
||||
if submit_self_task is None and host_client is not None:
|
||||
submit_self_task = host_client.submit_self_task
|
||||
submission_available = submit_self_task is not None
|
||||
|
||||
def _running_devices() -> list[dict[str, str]]:
|
||||
return [
|
||||
{
|
||||
"id": device.id,
|
||||
"label": device.name or device.id,
|
||||
}
|
||||
for device in manager.list_devices()
|
||||
]
|
||||
|
||||
def _session_token(request: Request) -> str | None:
|
||||
return request.cookies.get(SESSION_COOKIE_NAME)
|
||||
@@ -426,22 +477,181 @@ def create_console_app(
|
||||
entries=entries,
|
||||
)
|
||||
|
||||
def _tasks_list_context(
|
||||
session: SessionState,
|
||||
*,
|
||||
goal_value: str = "",
|
||||
selected_device: str = AUTOMATIC_DEVICE_VALUE,
|
||||
submission_error: str | None = None,
|
||||
submission_notice: str | None = None,
|
||||
submission_unknown: bool = False,
|
||||
status_code: int = 200,
|
||||
) -> dict[str, Any]:
|
||||
devices = _running_devices()
|
||||
return {
|
||||
"title": "Tasks",
|
||||
"session": session,
|
||||
"csrf_token": session.csrf_token,
|
||||
"tasks": metadata_store.list_tasks()
|
||||
if metadata_store is not None
|
||||
else [],
|
||||
"metadata_store_missing": metadata_store is None,
|
||||
"devices": devices,
|
||||
"automatic_device_value": AUTOMATIC_DEVICE_VALUE,
|
||||
"selected_device": selected_device
|
||||
if any(d["id"] == selected_device for d in devices)
|
||||
or selected_device == AUTOMATIC_DEVICE_VALUE
|
||||
else AUTOMATIC_DEVICE_VALUE,
|
||||
"submission_available": submission_available,
|
||||
"goal_value": goal_value,
|
||||
"submission_error": submission_error,
|
||||
"submission_notice": submission_notice,
|
||||
"submission_unknown": submission_unknown,
|
||||
"status_code": status_code,
|
||||
}
|
||||
|
||||
@app.get("/tasks", response_class=HTMLResponse)
|
||||
async def tasks_page(
|
||||
request: Request,
|
||||
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)
|
||||
notice: str | None = None
|
||||
unknown = False
|
||||
error: str | None = None
|
||||
if request.query_params.get("submitted") == "1":
|
||||
task_id = request.query_params.get("task_id", "")
|
||||
if task_id:
|
||||
notice = (
|
||||
f"Task submitted. Cloud task ID: {task_id}. "
|
||||
"Track it from the Cloud console for execution progress."
|
||||
)
|
||||
elif request.query_params.get("outcome") == "unknown":
|
||||
unknown = True
|
||||
context = _tasks_list_context(
|
||||
session,
|
||||
submission_notice=notice,
|
||||
submission_unknown=unknown,
|
||||
submission_error=error,
|
||||
)
|
||||
return _render(
|
||||
"tasks_list.html",
|
||||
title="Tasks",
|
||||
session=session,
|
||||
tasks=tasks_list,
|
||||
status_code=context["status_code"],
|
||||
**{k: v for k, v in context.items() if k != "status_code"},
|
||||
)
|
||||
|
||||
@app.post("/tasks/submit", response_class=HTMLResponse)
|
||||
async def tasks_submit(
|
||||
request: Request,
|
||||
session: SessionState = Depends(require_csrf),
|
||||
) -> Response:
|
||||
if metadata_store is None:
|
||||
raise HTTPException(
|
||||
status_code=503, detail="task metadata store not configured"
|
||||
)
|
||||
if submit_self_task is None:
|
||||
context = _tasks_list_context(
|
||||
session,
|
||||
submission_error=(
|
||||
"Host submission client is not available yet. "
|
||||
"Wait for Host enrollment to complete, then retry."
|
||||
),
|
||||
)
|
||||
return _render(
|
||||
"tasks_list.html",
|
||||
status_code=503,
|
||||
**{k: v for k, v in context.items() if k != "status_code"},
|
||||
)
|
||||
form = await request.form()
|
||||
raw_goal = str(form.get("goal", ""))
|
||||
goal = raw_goal.strip()
|
||||
device_selection = str(form.get("device_id", AUTOMATIC_DEVICE_VALUE)).strip()
|
||||
explicit_device_id: str | None = None
|
||||
if device_selection and device_selection != AUTOMATIC_DEVICE_VALUE:
|
||||
snapshot_ids = {device.id for device in manager.list_devices()}
|
||||
if device_selection not in snapshot_ids:
|
||||
context = _tasks_list_context(
|
||||
session,
|
||||
goal_value=goal,
|
||||
selected_device=device_selection,
|
||||
submission_error=(
|
||||
"Selected device is no longer registered. "
|
||||
"Refresh and try again."
|
||||
),
|
||||
)
|
||||
return _render(
|
||||
"tasks_list.html",
|
||||
status_code=400,
|
||||
**{k: v for k, v in context.items() if k != "status_code"},
|
||||
)
|
||||
explicit_device_id = device_selection
|
||||
|
||||
if not goal:
|
||||
context = _tasks_list_context(
|
||||
session,
|
||||
goal_value=goal,
|
||||
selected_device=device_selection or AUTOMATIC_DEVICE_VALUE,
|
||||
submission_error="Goal cannot be empty.",
|
||||
)
|
||||
return _render(
|
||||
"tasks_list.html",
|
||||
status_code=400,
|
||||
**{k: v for k, v in context.items() if k != "status_code"},
|
||||
)
|
||||
|
||||
try:
|
||||
response = await submit_self_task(
|
||||
goal=goal, device_id=explicit_device_id
|
||||
)
|
||||
except HostAgentAPIError as exc:
|
||||
context = _tasks_list_context(
|
||||
session,
|
||||
goal_value=goal,
|
||||
selected_device=device_selection,
|
||||
submission_error=_safe_submission_error(str(exc.detail)),
|
||||
)
|
||||
return _render(
|
||||
"tasks_list.html",
|
||||
status_code=502,
|
||||
**{k: v for k, v in context.items() if k != "status_code"},
|
||||
)
|
||||
except HostTaskSubmissionUnknownError:
|
||||
return RedirectResponse(
|
||||
url="/tasks?outcome=unknown",
|
||||
status_code=303,
|
||||
)
|
||||
|
||||
task_id = _extract_task_id(response)
|
||||
if task_id is None:
|
||||
context = _tasks_list_context(
|
||||
session,
|
||||
goal_value=goal,
|
||||
selected_device=device_selection,
|
||||
submission_error=(
|
||||
"Host submission client returned an unexpected response."
|
||||
),
|
||||
)
|
||||
return _render(
|
||||
"tasks_list.html",
|
||||
status_code=502,
|
||||
**{k: v for k, v in context.items() if k != "status_code"},
|
||||
)
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
history_store.record_task_submission,
|
||||
task_id=task_id,
|
||||
device_id=explicit_device_id,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
params = f"submitted=1&task_id={task_id}"
|
||||
return RedirectResponse(url=f"/tasks?{params}", status_code=303)
|
||||
|
||||
@app.get("/tasks/{task_id}", response_class=HTMLResponse)
|
||||
async def task_detail_page(
|
||||
task_id: str,
|
||||
|
||||
Reference in New Issue
Block a user