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:
2026-07-14 17:02:41 +08:00
parent 99bde4febb
commit fb09924835
9 changed files with 1158 additions and 33 deletions
+1
View File
@@ -204,6 +204,7 @@ def create_application(
ttl_seconds=resolved_config.console_session_ttl_seconds
),
enrollment_client=console_enrollment_client,
host_client=client,
metadata_store=metadata_store,
timeline=timeline,
executor=executor,
+46 -10
View File
@@ -38,6 +38,21 @@ class StaleLeaseError(HostAgentAPIError):
pass
class HostTaskSubmissionUnknownError(RuntimeError):
"""Raised when a Host self-submission request's Cloud outcome is uncertain.
This is distinct from :class:`HostAgentAPIError` because the request may
have reached the control plane but the response was lost, the server
returned a 5xx, or the success payload was malformed. Retrying would
duplicate the create, so the caller must treat the task as unknown and
surface that to the operator.
"""
def __init__(self, reason: str) -> None:
super().__init__(f"host task submission outcome is unknown: {reason}")
self.reason = reason
class HostAgentEnrollmentClient:
def __init__(
self,
@@ -172,16 +187,37 @@ class HostAgentClient:
goal: str,
device_id: str | None = None,
) -> HostTaskSubmissionResponse:
response = await self._request(
"POST",
f"/internal/v1/hosts/{self.config.host_id}/tasks",
json={
"host_id": self.config.host_id,
"goal": goal,
"device_id": device_id,
},
)
return HostTaskSubmissionResponse.model_validate(response.json())
try:
response = await self._client.request(
"POST",
f"/internal/v1/hosts/{self.config.host_id}/tasks",
json={
"host_id": self.config.host_id,
"goal": goal,
"device_id": device_id,
},
headers={"Authorization": f"Bearer {self.config.token}"},
)
except httpx.TransportError as exc:
raise HostTaskSubmissionUnknownError(str(exc)) from exc
if response.status_code >= 500:
raise HostTaskSubmissionUnknownError(
f"control plane returned status {response.status_code}"
)
if not response.is_success:
_raise_api_error(response, stale_lease=False)
try:
payload = response.json()
except ValueError as exc:
raise HostTaskSubmissionUnknownError(
"control plane returned malformed success payload"
) from exc
try:
return HostTaskSubmissionResponse.model_validate(payload)
except Exception as exc:
raise HostTaskSubmissionUnknownError(
"control plane returned malformed success payload"
) from exc
async def claim(self) -> AssignmentModel | None:
response = await self._request(
@@ -55,6 +55,21 @@ class ConsoleHistoryStore:
{"revision": revision},
)
def record_task_submission(
self,
*,
task_id: str,
device_id: str | None,
) -> None:
if device_id:
summary = f"task submitted: {task_id} on {device_id}"
else:
summary = f"task submitted: {task_id} (automatic device)"
detail: dict[str, Any] = {"task_id": task_id}
if device_id is not None:
detail["device_id"] = device_id
self._insert("task_submission", summary, detail)
def list_recent(self, limit: int | None = None) -> list[dict[str, Any]]:
effective_limit = limit if limit is not None else self.limit
with self._connect() as connection:
+215 -5
View File
@@ -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,
@@ -1,20 +1,52 @@
{% extends "base.html" %}
{% block body %}
<h1>Tasks</h1>
{% if not tasks %}
<p>No tasks recorded.</p>
{% else %}
<table>
<thead><tr><th>Task ID</th><th>Status</th><th>Device</th><th>Created</th><th>Updated</th></tr></thead>
<tbody>{% for task in tasks %}
<tr>
<td><a href="/tasks/{{ task["id"] }}">{{ task["id"] }}</a></td>
<td>{{ task.get("status") or "" }}</td>
<td>{{ task.get("device_id") or "" }}</td>
<td>{{ task.get("created_at") or "" }}</td>
<td>{{ task.get("updated_at") or "" }}</td>
</tr>
{% endfor %}</tbody>
</table>
{% endif %}
{% endblock %}
<section id="task-submission">
<h2>Submit task to current Host</h2>
{% if submission_available %}
<form method="post" action="/tasks/submit">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<p>
<label for="goal">Goal</label><br>
<textarea id="goal" name="goal" rows="4" cols="60" required>{{ goal_value }}</textarea>
</p>
<p>
<label for="device_id">Target device</label><br>
<select id="device_id" name="device_id">
<option value="{{ automatic_device_value }}"{% if selected_device == automatic_device_value %} selected{% endif %}>Automatic (let Host choose an eligible device)</option>
{% for device in devices %}<option value="{{ device.id }}"{% if selected_device == device.id %} selected{% endif %}>{{ device.label }}</option>{% endfor %}
</select>
</p>
{% if submission_error %}<p class="error" id="submission-error">{{ submission_error }}</p>{% endif %}
{% if submission_unknown %}<p class="error" id="submission-unknown">Submission outcome is unknown. The task may have been queued. Check the Cloud console before submitting again.</p>{% endif %}
{% if submission_notice %}<p class="notice" id="submission-notice">{{ submission_notice }}</p>{% endif %}
<p><button type="submit">Submit task</button></p>
</form>
{% else %}
<p class="error" id="submission-unavailable">Host submission client is not available yet. Wait for Host enrollment to complete, then refresh.</p>
{% endif %}
</section>
<section id="local-tasks">
<h2>Local Runtime tasks</h2>
{% if metadata_store_missing %}
<p class="error">Task metadata store is not configured.</p>
{% elif not tasks %}
<p>No tasks recorded.</p>
{% else %}
<table>
<thead><tr><th>Task ID</th><th>Status</th><th>Device</th><th>Created</th><th>Updated</th></tr></thead>
<tbody>{% for task in tasks %}
<tr>
<td><a href="/tasks/{{ task["id"] }}">{{ task["id"] }}</a></td>
<td>{{ task.get("status") or "" }}</td>
<td>{{ task.get("device_id") or "" }}</td>
<td>{{ task.get("created_at") or "" }}</td>
<td>{{ task.get("updated_at") or "" }}</td>
</tr>
{% endfor %}</tbody>
</table>
{% endif %}
</section>
{% endblock %}