diff --git a/plugins/host_agent/routes.py b/plugins/host_agent/routes.py
index 269296b..da49482 100644
--- a/plugins/host_agent/routes.py
+++ b/plugins/host_agent/routes.py
@@ -809,3 +809,38 @@ async def provision_via_ansible():
if err:
return _error(400, "provision_failed", err)
return redirect(url_for("ansible.run_detail", run_id=run.id))
+
+
+@host_agent_bp.post("/update")
+@require_role(UserRole.admin)
+async def update_via_ansible():
+ """Refresh agent.py on already-installed hosts via the bundled update
+ playbook. Connects as the managed steward account — no token rotation, no
+ config rewrite (the host keeps its existing identity)."""
+ from steward.core.capabilities import has_capability, invoke_capability
+ from steward.ansible.sources import BUILTIN_SOURCE_NAME
+
+ if not has_capability("ansible.run_playbook"):
+ return _error(400, "ansible_unavailable", "Ansible is not available")
+
+ form = await request.form
+ scope = (form.get("inventory_scope", "") or "").strip()
+ if not (scope.startswith("steward:target:")
+ or scope.startswith("steward:group:")
+ or scope == "steward:all"):
+ return _error(400, "bad_scope", "Choose a target or group")
+
+ url = public_base_url(request)
+ actor_role = UserRole(session.get("user_role", "viewer"))
+ run, _source, err = await invoke_capability(
+ "ansible.run_playbook", actor_role,
+ current_app._get_current_object(), # type: ignore[attr-defined]
+ source_name=BUILTIN_SOURCE_NAME,
+ playbook_path="host_agent/update.yml",
+ inventory_scope=scope,
+ params={"extra_vars": [f"steward_url={url}"]},
+ triggered_by=session.get("user_id"),
+ )
+ if err:
+ return _error(400, "update_failed", err)
+ return redirect(url_for("ansible.run_detail", run_id=run.id))
diff --git a/plugins/host_agent/templates/settings_list.html b/plugins/host_agent/templates/settings_list.html
index 77fdf71..7cbff8f 100644
--- a/plugins/host_agent/templates/settings_list.html
+++ b/plugins/host_agent/templates/settings_list.html
@@ -31,41 +31,63 @@
{% if ansible_available %}
+{% set scope_select %}
+
+
+{% endset %}
+
-
Provision a fresh host
-
- First contact for a host Steward can't yet reach by key. Creates a
- steward login account with passwordless sudo, installs the managed
- key, then installs the agent — over a one-time bootstrap password (used for this
- run only, never stored). After this, use Deploy below for updates.
+
Agent lifecycle via Ansible
+
+ These actions run bundled Ansible playbooks to deploy and maintain the
+ Steward monitoring agent on your inventory hosts.
+ Steward generates each host's API token automatically and connects over SSH as the managed
+ steward account — you'll be dropped into the live Ansible run to watch it.
+
+{% if managed_key_set and (deploy_targets or deploy_groups) %}
+
+
1 · Provision a fresh host
+
+ First contact for a brand-new host. Connects with a one-time bootstrap
+ user + password (used for this run only, never stored), creates the steward
+ login account with passwordless sudo, installs the managed SSH key, then installs the
+ agent. Run this once per host.
+
- {% endif %}
-
Deploy via Ansible
+
2 · Install / enroll agent
- Install (or update) the agent on Ansible inventory hosts in one run — no per-host curl | sh.
- A fresh token is minted per host and injected into the run; existing agents are rotated to the new token.
+ For a host that's already provisioned (has the steward account) but has no
+ agent yet — or to re-enroll one. Connects as steward with the managed key,
+ mints a fresh token, and installs the agent. No curl | sh needed.
- {% if not (deploy_targets or deploy_groups) %}
-
- No Ansible inventory targets yet. Add some under Ansible → Browse.
-
- {% else %}
- {% endif %}
+
+
+
3 · Update agents
+
+ Refresh the agent binary on hosts already running it. Connects as steward
+ with the managed key and restarts the service — the token and config are left untouched,
+ so identity is preserved.
+
+
+
+{% endif %}
{% endif %}
diff --git a/steward/ansible/bundled/host_agent/provision.yml b/steward/ansible/bundled/host_agent/provision.yml
index db35589..73be606 100644
--- a/steward/ansible/bundled/host_agent/provision.yml
+++ b/steward/ansible/bundled/host_agent/provision.yml
@@ -50,9 +50,13 @@
group: "{{ steward_user }}"
mode: "0700"
- - name: Authorize the managed public key
+ - name: Authorize the managed public key (replace any prior steward-managed key)
ansible.builtin.lineinfile:
path: "/home/{{ steward_user }}/.ssh/authorized_keys"
+ # Match on the ' steward-managed' comment so a rotated key REPLACES the
+ # old one in place rather than stacking a second authorized key. Any
+ # keys the operator added by hand (different comment) are left untouched.
+ regexp: ' steward-managed$'
line: "{{ steward_pubkey }}"
create: true
owner: "{{ steward_user }}"
diff --git a/steward/ansible/bundled/host_agent/update.yml b/steward/ansible/bundled/host_agent/update.yml
new file mode 100644
index 0000000..8dff5ff
--- /dev/null
+++ b/steward/ansible/bundled/host_agent/update.yml
@@ -0,0 +1,47 @@
+---
+# Update an already-installed Steward host agent: refresh agent.py and restart.
+#
+# Deliberately does NOT touch the registration token or /etc/steward-agent.conf
+# — the host already has a working token, so an update leaves identity alone and
+# only swaps the agent binary. Connects as the managed `steward` account (set
+# globally as the SSH user); no bootstrap password or token injection needed.
+#
+# For a fresh host with no agent, use provision.yml (new host) or install.yml
+# (already-provisioned host) instead — those mint and install the token.
+- name: Update Steward host agent
+ hosts: all
+ become: true
+ gather_facts: false
+ vars:
+ steward_url: ""
+ tasks:
+ - name: Require steward_url
+ ansible.builtin.assert:
+ that:
+ - steward_url | length > 0
+ fail_msg: "Pass steward_url as an extra-var (the Update action sets it)."
+
+ - name: Confirm the agent is already installed
+ ansible.builtin.stat:
+ path: /usr/local/lib/steward-agent/agent.py
+ register: agent_file
+
+ - name: Fail clearly if the agent is missing
+ ansible.builtin.fail:
+ msg: "No agent at /usr/local/lib/steward-agent — provision or install this host first."
+ when: not agent_file.stat.exists
+
+ - name: Refresh agent.py
+ ansible.builtin.get_url:
+ url: "{{ steward_url }}/plugins/host_agent/agent.py"
+ dest: /usr/local/lib/steward-agent/agent.py
+ mode: "0755"
+ force: true
+ notify: restart steward-agent
+
+ handlers:
+ - name: restart steward-agent
+ ansible.builtin.systemd:
+ name: steward-agent
+ state: restarted
+ daemon_reload: true
diff --git a/steward/settings/routes.py b/steward/settings/routes.py
index 8944f58..64d623c 100644
--- a/steward/settings/routes.py
+++ b/steward/settings/routes.py
@@ -197,6 +197,7 @@ async def ansible_generate_key():
"""
from steward.core.crypto import generate_ssh_keypair
+ form = await request.form
private_pem, public_line = generate_ssh_keypair()
async with current_app.db_sessionmaker() as db:
async with db.begin():
@@ -205,6 +206,10 @@ async def ansible_generate_key():
await _reload_app_config()
await log_audit(current_app, session.get("user_id"), session.get("username", ""),
"settings.saved", detail={"section": "ansible.managed_key"})
+ # Allow the inline trigger on other pages (e.g. Host Agents) to return there.
+ nxt = (form.get("next", "") or "").strip()
+ if nxt.startswith("/") and not nxt.startswith("//"):
+ return redirect(nxt)
return redirect(url_for("settings.ansible"))
diff --git a/steward/templates/base.html b/steward/templates/base.html
index 7c082ca..2e81c39 100644
--- a/steward/templates/base.html
+++ b/steward/templates/base.html
@@ -146,7 +146,9 @@ textarea { resize: vertical; }
.empty { color: var(--text-muted); font-size: 0.9rem; padding: 2rem; text-align: center; }
/* Breadcrumbs */
-.breadcrumb { display:flex; align-items:center; flex-wrap:wrap; gap:0.35rem; font-size:0.8rem; color:var(--text-dim); margin-bottom:1rem; }
+/* Breadcrumb renders as a kicker line tucked directly above the page header
+ (.page-title), so nested and top-level views share one consistent header. */
+.breadcrumb { display:flex; align-items:center; flex-wrap:wrap; gap:0.35rem; font-size:0.78rem; color:var(--text-dim); margin:0 0 0.45rem; }
.breadcrumb a { color:var(--text-muted); }
.breadcrumb a:hover { color:var(--text); }
.breadcrumb-sep { color:var(--border-mid); }
@@ -235,7 +237,6 @@ function setTimeRange(val) {
- {% block breadcrumb %}{% endblock %}
{% if plugin_failures and session.user_role == 'admin' %}