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
+117
View File
@@ -0,0 +1,117 @@
# Host Agent Local Console Task Submission
The Host Agent local Console adds a **Submit task to current Host** form on
the authenticated Tasks page. The form lets an operator who is already
signed in to the loopback Console enqueue a goal task for the current Host
without going through the Cloud Console or the public SDK.
The submission reuses the existing Host self-submission contract (a single
authenticated `POST /internal/v1/hosts/{host_id}/tasks` against the control
plane). It does not introduce a new Cloud endpoint, a public task scope, or
a cross-Host target.
## Automatic vs explicit device
The form offers two submission modes for the device target:
- **Automatic** — the form omits the `device_id` field. The Cloud scheduler
picks any eligible device currently registered for the authenticated
Host. The Console labels this option *Automatic (let Host choose an
eligible device)*.
- **Explicit device** — the operator selects one of the device IDs that are
currently registered in the running `DeviceManager`. In
enrollment-managed deployments these are the Cloud device IDs assigned by
the Host enrollment flow (the same IDs the heartbeat sends to the
control plane), not the local configuration IDs.
The Console revalidates the selected device against a fresh
`DeviceManager.list_devices()` snapshot before issuing the outbound
request. If the device was removed between page render and POST, the
Console returns a validation error and never calls the Host client.
## Required state and policy
- The Console session must be valid (the same local account login used for
the rest of the Console). The submission form is rendered only for
authenticated sessions, and the POST handler requires both a valid
session and the matching CSRF token.
- The Host must have completed self-enrollment. The Host self-submission
client is wired in by `host_agent.app.create_application` from the same
asynchronous `HostAgentClient` instance that drives heartbeat, claim,
lease renewal, and result reporting, so the submission form disappears
with a clear *submission client is not available yet* message until
enrollment is complete.
- The Cloud control plane must have `self_submission_enabled = true` in
the Host governance policy. A disabled policy is returned to the
operator as a definitive 4xx rejection ("Host self-submission is
disabled") without recording a successful audit entry; the cached
policy is *not* used for local authorization, so a stale cache never
silently denies a newly enabled Host.
## Cloud queue semantics
- Task creation is **not** idempotent. After the POST reaches the control
plane, the Cloud scheduler assigns a task ID. The Console never retries
the creation call. Even when the response is lost, a 5xx is returned, or
the success payload is malformed, the Console only reports the outcome
as **unknown** and instructs the operator to check the Cloud console
before submitting again. This avoids the known failure mode where one
operator click duplicates a task in the Cloud queue.
- The local Console does not show queue progress for a freshly submitted
task. After a successful submission the form redirect carries the Cloud
task ID in the URL and the Tasks page shows *Task submitted. Cloud task
ID: \<id\>*; execution progress, lease state, and the terminal result
continue to appear on the local Tasks page only after the Cloud
scheduler assigns the task to this Host and the assignment is processed
locally.
## Outcome-unknown procedure
The Console intentionally distinguishes the **unknown outcome** case from
both a confirmed submission and a definitive rejection:
- A transport failure (DNS, connect, read timeout, dropped connection),
any 5xx response, or a malformed 2xx payload makes the local client
raise `HostTaskSubmissionUnknownError`. The Console catches it and
redirects the operator to `GET /tasks?outcome=unknown`, which renders a
*Submission outcome is unknown. The task may have been queued. Check
the Cloud console before submitting again.* notice. No history entry is
written and no success confirmation is shown.
- A definitive 4xx rejection (for example, a disabled self-submission
policy or a target device that the control plane does not recognize as
owned by this Host) raises `HostAgentAPIError`. The Console renders the
Cloud's `detail` message safely through Jinja autoescape without
echoing the goal text or any credentials. No history entry is written
and no task ID is shown.
## Auditing
- Confirmed submissions write a `task_submission` history row that records
the Cloud task ID and, when applicable, the target device ID. The row
deliberately does **not** persist the goal, the Host token, the
session cookie, the lease secret, or any other operator credential. The
row is bounded by the existing `console_history_limit` and survives
process restarts.
- A local audit write failure is best-effort: it does not turn a
Cloud-confirmed submission into a retryable failure. A `task_submission`
history row is only written after the Cloud returns a 2xx with a usable
task ID, so a rejection or unknown outcome can never produce a
misleading successful audit entry.
## Operator checklist
1. Sign in to the loopback Console with a valid local account.
2. Open **Tasks**.
3. Type a non-empty goal in the textarea.
4. Choose **Automatic** to let the Cloud pick a device, or pick a listed
device ID for an explicit target.
5. Click **Submit task**.
6. On confirmation, the page shows *Task submitted. Cloud task ID: \<id\>*.
Track the task from the Cloud console; the local Tasks list will fill
in once the scheduler assigns the task to this Host.
7. If the page shows the *outcome is unknown* notice, check the Cloud
console for a matching task before submitting again.
8. If the page shows a Cloud rejection, fix the underlying issue (policy,
device ownership, lease state) and submit again. The page does not
preserve the goal text in the URL, in the error message, or in the
history; retype the goal when retrying.