diff --git a/docs/AGENT_SECURITY.md b/docs/AGENT_SECURITY.md
index 2fc1d4ed9..3d9d56d89 100644
--- a/docs/AGENT_SECURITY.md
+++ b/docs/AGENT_SECURITY.md
@@ -200,6 +200,29 @@ The safe runner accepts only versioned host update/storage-cleanup, Proxmox
guest lifecycle, and container lifecycle/update requests. Generic shell,
`exec`, unrestricted `read_file`, and deploy operations are rejected.
+**Settings → Infrastructure → Agent Doctor** presents this boundary as
+evidence, not an inferred security grade. The collector reports whether it is
+root, its service user and local command-authority ceiling, and whether the
+typed helper, SMART helper, and `pct` helper are configured. Pulse separately
+joins the current tenant's token inventory and admitted command sessions to
+show whether the collector credential is known and execution-scoped, whether
+a host-bound runner credential is active, and whether a compatible
+`action-runner` / `typed_actions.v1` session is actually connected. Missing
+evidence remains unknown; collector health never proves remediation readiness.
+
+Runner enrollment is one host at a time. The issuance request must resolve to
+exactly one non-conflicted monitored agent ID and normalized hostname in the
+current tenant. The returned secret is shown once and held only in the open
+Agent Doctor page's memory; it is not written to browser storage, a URL, a
+diagnostic report, or an installer command. The generated handoff first prompts
+for the secret through `/dev/tty` into the root-owned
+`/etc/pulse-agent-runner/token` file with mode `0600`, then passes only that
+file path through `--action-token-file`. Issuing again rotates the credential
+for that tenant/host binding: Pulse durably replaces the previous record as one
+transaction and restores it if persistence fails. A successful rotation makes
+the previous secret invalid, so complete the new token-file handoff before
+expecting a disconnected runner to reconnect.
+
The existing combined collector command path remains available only as the
explicit legacy/full-trust migration profile. It is not part of the typed
helper/runner security claim and will remain until supported command-enabled
@@ -217,6 +240,16 @@ install.sh --safe-profile-rollback ...
Inspection makes no changes. Apply snapshots collector/helper files and
identity before switching profiles, and rollback restores that snapshot. The
separately installed action runner is not changed by either operation.
+Inspection reports platform support, the current unit user and groups, ambient
+capabilities, collector-binary owner and mode, enabled provider flags, helper
+and collector-command state, independent runner presence, and the calculated
+typed-helper target and degraded Docker/action differences. Apply is supported
+only on reviewed standard Linux systemd hosts: it retains the monitoring token
+and agent identity, lowers the collector to monitoring-only, removes legacy
+sudo/Docker-group/ambient authority, requires collector health, helper socket
+health, and declared server registration before commit, and automatically
+restores the exact snapshot on failure. Ordinary `--update` preserves the
+installed profile and never performs this migration.
The older `--least-privilege` profile without `--enable-privileged-helper`
remains available for compatibility. Its trade-offs are documented below.
diff --git a/docs/UNIFIED_AGENT.md b/docs/UNIFIED_AGENT.md
index 6f4bda0ac..7968c0114 100644
--- a/docs/UNIFIED_AGENT.md
+++ b/docs/UNIFIED_AGENT.md
@@ -111,6 +111,19 @@ without removing the collector. The runner accepts only the documented typed
host, Proxmox guest, and container operations; shell, generic exec,
unrestricted file reads, and deploy requests remain forbidden.
+Agent Doctor shows the reported collector service user, command-authority
+ceiling, collector credential scope, and typed-helper configuration separately
+from the tenant- and host-bound action-runner credential and connected-session
+state. It does not treat a healthy collector as proof that actions are ready.
+For an eligible Linux systemd host, enrollment returns one credential reveal
+kept only in the open page's memory. Start the generated `/dev/tty` prompt,
+copy and paste the secret into that prompt to write the private root-owned
+token file, and then run the installer command, which carries only
+`--action-token-file ` in its arguments. Re-enrollment atomically
+replaces the prior credential for the same tenant and canonical agent ID; a
+failed persistence write retains the previous credential, while a successful
+rotation invalidates it.
+
Use `--safe-profile-inspect` to report the current profile and calculated
differences without changing the host. `--safe-profile-apply` performs the
explicit collector/helper migration and retains a rollback snapshot;
@@ -118,6 +131,16 @@ explicit collector/helper migration and retains a rollback snapshot;
Linux systemd and fail closed elsewhere. Appliance, non-systemd, Windows, and
macOS installs remain explicit legacy/full-trust profiles until their own
runtime and migration boundaries are qualified.
+Inspection includes unit identity/groups, ambient capabilities, binary
+ownership/mode, provider flags, helper/command state, independent runner
+presence, and the expected telemetry/action degradation. Apply preserves the
+collector token and agent identity, commits only after local health, helper
+socket, and server registration checks, and restores the exact prior
+collector/helper profile on failure. It never changes a separately installed
+runner, and ordinary `--update` never implies a profile migration. The secure
+runtime separation remains a proposed hardening lane; the product-wide default
+does not change until representative real Linux hosts pass migration,
+activation, rollback, provider-parity, and action-session qualification.
On Linux, the host module automatically checks for `virsh`. When the agent can
open the default libvirt connection read-only, defined domains appear as VM
diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md
index ba39e9f75..2e75cfbd0 100644
--- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md
+++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md
@@ -815,6 +815,44 @@ only while Agent Doctor is open, enriches canonical connection-ledger rows by
stable `connectionId`, and retains ledger-only fallback rows when structured
evidence is absent.
+Secure-runtime posture on that read model is factual and split by authority
+source. The collector-authored report supplies effective-root state, service
+user, local `commandAuthority`, and configured typed/SMART/`pct` helpers. The
+server adds only evidence it can independently observe: whether the reporting
+token is present and carries `agent:exec`, whether the current organization has
+an active action-runner credential bound to the canonical agent ID, and whether
+the organization-scoped live-session registry contains that agent under the
+closed `action-runner` role and `typed_actions.v1` capability. Connected
+runner version, connection time, binding version, receipt protocol, preflight
+protocol, and Docker-observation protocol remain separate optional facts.
+Absence is not projected as safe, and neither collector liveness nor a healthy
+fleet verdict may stand in for action-session readiness.
+
+Agent Doctor may issue or rotate an action-runner credential only after the
+selected diagnostic proves a non-root, monitoring-only collector with a known
+non-exec credential and typed helper, resolves to a standard-looking Linux
+systemd installation, and has one complete canonical agent ID/hostname pair.
+The operator-only endpoint re-resolves that pair against exactly one live,
+non-conflicted, non-integration host in the request tenant. The resulting
+credential has only `agent:exec`, the explicit `action-runner` role,
+`typed_actions.v1`, binding version 1, organization ID, canonical agent ID, and
+normalized hostname. Rotation replaces only the earlier action-runner record
+for that organization/agent binding; the token inventory is saved atomically,
+and a failed save restores the complete previous inventory before the secret
+can be returned.
+
+The raw secret is a one-time UI reveal, not a diagnostic field. The page keeps
+it in component memory only, permits at most one successful issuance for that
+target in the page session, and clears it explicitly or on navigation. The
+host handoff prompts through `/dev/tty`, writes
+`/etc/pulse-agent-runner/token` under a root-owned `0700` directory with file
+mode `0600`, and puts only `--action-token-file` plus canonical identity in the
+installer command. Secrets must not enter URLs, browser storage, diagnostics,
+command arguments, or logs. A successful rotation invalidates the prior
+credential immediately; the UI therefore blocks enrollment while a compatible
+runner session is already connected and presents rotation only for an issued
+but disconnected binding.
+
Repair entries remain handoffs to existing lifecycle operations:
`copy_upgrade_command` renders a local operator command and
`allow_reenroll` invokes the existing removed-agent flow. Agent Doctor exposes
@@ -6720,6 +6758,38 @@ the only server delivery path: it rejects non-Linux targets, serves the exact
separate helper artifact with checksum plus detached signatures, and requires a
published signed release asset when no local binary exists.
+### Safe collector profile migration is an explicit transaction
+
+`scripts/install.sh --safe-profile-inspect` is a standalone no-write action.
+It reports platform support, detected current profile, unit user and groups,
+ambient capabilities, collector-binary owner/mode, host/Docker/Kubernetes/
+Proxmox provider flags, typed-helper and collector-command state, independent
+runner presence, and the calculated typed-helper target plus Docker/action
+degradation. Those host-local facts are broader than the remote Agent Doctor
+projection and must not be guessed from it.
+
+`--safe-profile-apply` is supported only for an existing collector on reviewed
+standard Linux systemd hosts. It snapshots exact collector and helper binaries,
+units, legacy sudoers/wrappers, service enable/active state, Docker-group
+membership, credential-directory metadata, monitoring token files, agent ID,
+and connection state before mutation. It then installs root-owned collector
+and helper artifacts, lowers the collector to monitoring-only, removes legacy
+sudo, Docker-group, and ambient authority, and commits an atomic current/prior
+profile record only after collector health, helper-socket health, and declared
+server registration succeed. Any pre-commit failure restores the snapshot;
+`--safe-profile-rollback` restores the retained committed snapshot explicitly.
+Rollback may restore only privilege recorded in that explicit prior profile and
+must never invent sudo, Docker-group, ambient, root, or command authority. The
+action runner is independently installed and is never snapshotted, stopped,
+rewritten, or restored by this transaction. Ordinary `--update` preserves the
+installed profile and cannot trigger migration.
+
+This remains candidate-lane evidence, not a product-wide default flip. The
+secure collector profile stays explicit until representative real Linux hosts
+qualify migration and failure rollback, typed-helper update activation,
+provider parity and documented degradation, and separately credentialed live
+action sessions. Unsupported platforms continue to fail closed on migration.
+
### Command dispatch is context-honest and abandoned executions are canceled
The agent command transport now refuses to dispatch work its caller has
diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md
index 58913640d..847ab3cc5 100644
--- a/docs/release-control/v6/internal/subsystems/api-contracts.md
+++ b/docs/release-control/v6/internal/subsystems/api-contracts.md
@@ -32,15 +32,19 @@ and its two signature sidecars.
The action runner has a parallel Linux-only, rate-limited signed-binary
contract at `GET|HEAD /download/pulse-agent-runner?arch=linux-*`; it never
falls back to a collector or helper asset. `POST
-/api/agents/action-runner/credential` is an admin and `actions:execute`
-operation that accepts one canonical monitored host identity and returns a
-new, separately persisted `agent:exec` credential bound to organization,
-agent ID, normalized hostname, runtime role `action-runner`, and capability
-`typed_actions.v1`. Monitoring credentials cannot call the issuance route or
-authenticate an action-runner session, and action-runner credentials are not
-collector report/config credentials. Unknown request fields, ambiguous or
-conflicted host identities, and persistence failures fail before any usable
-credential is returned.
+/api/agents/action-runner/credential` is an operator-only admin,
+`settings:write`, and `actions:execute` operation. It accepts one canonical
+monitored `agentId` / `hostname` pair and returns a new, separately persisted
+`agent:exec` credential bound to organization, agent ID, normalized hostname,
+runtime role `action-runner`, binding version 1, and capability
+`typed_actions.v1`. The route re-resolves the pair against exactly one live,
+non-conflicted, non-integration host in the request tenant. Issuing again
+atomically replaces only the earlier action-runner record for that tenant and
+agent ID; a persistence failure restores the complete token inventory and
+returns no usable new secret. Monitoring credentials cannot call the issuance
+route or authenticate an action-runner session, and action-runner credentials
+cannot report, read agent configuration, or manage collectors. Unknown request
+fields and ambiguous, mismatched, or conflicted host identities fail closed.
The API runtime is decomposed along production domain boundaries so Go can
compile and execute domain qualification packages concurrently. Shared tenant
@@ -10042,14 +10046,29 @@ execution-profile projection; neither external clients nor model output can
select or widen the surface.
The agent fleet diagnostics payload (`GET /api/agents/diagnostics`) now
-carries an optional per-agent `privilege` object — `runningAsRoot`,
-`serviceUser`, `smartctlHelper`, `pctHelper` — mirrored by the frontend
-transport in `frontend-modern/src/api/agentDiagnostics.ts`
-(`AgentFleetDiagnosticPrivilege`). The field is descriptive fleet evidence:
-it appears only when the agent reported a profile, it never carries
-credentials or paths, and consumers must not derive health status from it.
-The unified agent report contract (`pkg/agents/host/report.go`) gains the
-matching agent-authored `privilege` block with the same fields.
+carries an optional per-agent `privilege` object mirrored by
+`frontend-modern/src/api/agentDiagnostics.ts`
+(`AgentFleetDiagnosticPrivilege`). The agent-authored portion is
+`runningAsRoot`, `serviceUser`, `commandAuthority`, `typedHelper`,
+`smartctlHelper`, and `pctHelper`. The server may add `credentialKnown` and
+`credentialExec` from the reporting-token inventory plus
+`actionRunnerCredentialIssued`, `actionRunnerCredentialActive`,
+`actionRunnerRuntimeRole`, `actionRunnerCapability`,
+`actionRunnerBindingVersion`, `actionRunnerConnected`,
+`actionRunnerVersion`, `actionRunnerConnectedAt`, and the receipt, preflight,
+and Docker-observation protocol versions from the organization-scoped
+credential and admitted-session registries. The unified agent report contract
+(`pkg/agents/host/report.go`) carries only the agent-authored subset.
+
+These fields are descriptive fleet evidence, not authorization and not a
+health reason. They never contain raw credentials, credential paths, helper
+paths, unit groups/capabilities, or binary ownership. Consumers must not infer
+those host-local facts, safe-profile completion, or action readiness from an
+absent field, collector health, or the mere existence of a runner credential.
+Agent Doctor may show the raw `token` returned by the issuance response only as
+a one-time component-memory reveal; it must not persist that value in browser
+storage, URLs, diagnostics, installer commands, or process arguments. The
+generated installer handoff carries only a private `--action-token-file` path.
### Proxmox node network inventory is an optional canonical facet
diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md
index 72ba8968a..fcd18410f 100644
--- a/docs/release-control/v6/internal/subsystems/monitoring.md
+++ b/docs/release-control/v6/internal/subsystems/monitoring.md
@@ -38,6 +38,14 @@ command-capable runtime whose monitoring credential cannot re-enable commands
is a scope-mismatch warning; an actively command-enabled runtime without
`agent:exec` remains critical. These facts do not change collection truth or
make a least-privilege service unhealthy by themselves.
+Action-runner posture is a server-side join, not collector-reported monitoring
+truth. The collector may report only that its typed local helper is configured;
+Agent Doctor separately overlays the current tenant's host-bound runner-token
+inventory and admitted command-session inventory. A runner is connected only
+when the canonical agent ID matches an admitted `action-runner` session with
+the closed `typed_actions.v1` capability. Credential presence, expiry,
+connection, version, and protocol facts remain distinct, and none may be
+inferred from collector health or helper configuration.
Monitor construction also applies the persisted alert schedule's normalized
initial-delivery target to the tenant notification manager. This is runtime
wiring only: monitoring does not choose destinations or own notification
diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md
index 573d9bdbc..38e1062f8 100644
--- a/docs/release-control/v6/internal/subsystems/security-privacy.md
+++ b/docs/release-control/v6/internal/subsystems/security-privacy.md
@@ -65,6 +65,13 @@ shell/exec, unrestricted `read_file`, deploy, and trusted-origin bypasses are
forbidden. The legacy combined command channel remains a disclosed full-trust
migration boundary only until runner enrollment and live session parity are
qualified; it is not safe-profile authority.
+Action-runner issuance and rotation are one durable host-bound transition per
+organization and canonical agent ID. Re-issuance replaces the prior runner
+record even when the monitored hostname has changed, returns the new plaintext
+secret only after persistence succeeds, and restores the complete prior token
+inventory if persistence fails. A successful rotation invalidates the previous
+secret immediately; it never widens the collector credential or turns a
+monitoring session into an action session.
Own Pulse's canonical privacy disclosures, outbound usage-data boundary,
and the security-facing settings surfaces that expose authentication posture,
diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md
index d781108eb..0db15bd2c 100644
--- a/docs/release-control/v6/internal/subsystems/storage-recovery.md
+++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md
@@ -34,6 +34,12 @@ preserves collector identity and installation files only; it must not rewrite
storage/recovery evidence or reinterpret a restored legacy collector profile as
recovery success. Typed `host.storage_cleanup` remains governed remediation,
not storage-recovery authority inferred from the credential itself.
+Runner credential rotation follows the shared token-inventory commit boundary:
+failed persistence restores the complete prior inventory and returns no new
+secret, while successful persistence replaces the previous organization/agent
+binding and makes its secret unusable. That rollback protects restart-time
+credential truth only; it is not a customer backup, recovery point, restore
+operation, or storage-cleanup grant.
The helper-backed collector updater likewise retains only one fixed
last-known-good executable and an identity-bound activation receipt. That
binary rollback is agent lifecycle state, not a Pulse storage snapshot,
diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json
index 1c6a901e2..6321ee66d 100644
--- a/frontend-modern/browser-verification.json
+++ b/frontend-modern/browser-verification.json
@@ -1,28 +1,26 @@
{
"version": 1,
- "base_sha": "d607d5cf4616e52b2fc7a576846dbe7204e9a7cf",
- "verified_at": "2026-08-29T23:04:06Z",
+ "base_sha": "28be70071e8a7c2d0b9663f5d2eab5f5ba2f6528",
+ "verified_at": "2026-08-29T23:37:43Z",
"result": "passed",
"changed_paths": [
- "frontend-modern/src/features/docker/DockerPageSurface.tsx",
- "frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx",
- "frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx"
+ "frontend-modern/src/api/agentDiagnostics.ts",
+ "frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx",
+ "frontend-modern/src/components/Settings/infrastructureAgentUpdateCommandsModel.ts"
],
"content_sha256": {
- "frontend-modern/src/features/docker/DockerPageSurface.tsx": "013f32dc0201ed993a68763a53e88374fe9b08892e321806fb5dbb9aa51bf77f",
- "frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx": "c8ab6ff8abdf7c61a5c75f0d024a682c1b83a10efa46278c87b946afe654ef60",
- "frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx": "2158aed352e98b2fc1f0d308b494d0a8d387aefc28fbf90bbc92a498e49e161a"
+ "frontend-modern/src/api/agentDiagnostics.ts": "1e3c207615fd27fedf811b40830789f779b80b570085c54970cfb277f54eb07f",
+ "frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx": "0053b3fa0be0fbf463a6569c4fef4d977401647fe7c717a27543eb023a69e8ff",
+ "frontend-modern/src/components/Settings/infrastructureAgentUpdateCommandsModel.ts": "5157fd513d3d149fdd844e0e807d0d73879661ab165cf2850efa8286a888ecbf"
},
"routes": [
- "/proxmox/overview",
- "/proxmox/backups/date",
- "/docker/overview",
- "/kubernetes/overview"
+ "/settings/infrastructure/agent-doctor",
+ "/settings/infrastructure/agent-doctor?agents=host-node-mock-cluster-4-pve22"
],
"viewports": [
{
- "width": 1280,
- "height": 720
+ "width": 1440,
+ "height": 1000
},
{
"width": 390,
@@ -30,18 +28,17 @@
}
],
"states": [
- "Proxmox Overview with the node table and paginated guest inventory populated from only the source-scoped Overview owner",
- "Proxmox Backups with PBS servers, backup-health summary, activity chart, filters, and workload rows composed from the existing guest snapshot plus PBS rows",
- "Docker Overview with provider-owned hosts and containers populated from one source-scoped inventory request",
- "Kubernetes Overview with provider-owned clusters, deployments, and pods populated from one source-scoped inventory request",
- "all four provider states at desktop and narrow viewport widths"
+ "full fleet summary and expanded legacy Linux agent with explicit safe-profile inspect and apply guidance",
+ "eligible monitoring-only collector with typed-helper configuration and no action-runner credential or session",
+ "one-time host-bound action-runner credential reveal with private token prompt and separate runner installer handoff",
+ "credential-cleared page-session state with repeat issuance disabled",
+ "eligible safe collector at desktop and narrow viewport widths"
],
"interactions": [
- "reloaded Proxmox Overview with browser request tracing and verified that only its ten paginated source=proxmox Overview requests ran, with no inactive Storage, Backups, Replication, Ceph, or Mail hydration",
- "navigated from Proxmox Overview to Backups and verified that the transition added exactly one source=pbs request without repeating the paginated guest inventory",
- "reloaded Docker Overview and verified that its only inventory request included source=docker and populated the host and container surfaces",
- "reloaded Kubernetes Overview and verified that its only inventory request included source=kubernetes and populated the cluster and workload surfaces",
- "repeated the provider route and transition checks at 390 by 844 and verified the tables, navigation, filters, status summaries, and workload rows remained present",
- "reset the temporary viewport override and closed the verification tab"
+ "expanded a legacy Linux agent and copied the standalone safe-profile inspection command",
+ "issued an isolated non-persistent action-runner credential fixture and verified the normalized host identity, closed runtime role, and typed capability before reveal",
+ "verified the revealed secret was absent from the private token-file command, installer command, URL, and diagnostic report",
+ "cleared the one-time credential and verified the secret disappeared while repeat issuance stayed disabled for the page session",
+ "verified the 390 by 844 layout had no document overflow, checked the rendered pixels, reset the viewport override, and removed the temporary request fixture"
]
}
diff --git a/frontend-modern/public/docs/AGENT_SECURITY.md b/frontend-modern/public/docs/AGENT_SECURITY.md
index 5dda2eb37..3d9d56d89 100644
--- a/frontend-modern/public/docs/AGENT_SECURITY.md
+++ b/frontend-modern/public/docs/AGENT_SECURITY.md
@@ -28,8 +28,8 @@ root read access:
- install it only on hosts you trust Pulse to monitor;
- keep the agent token scoped to that Pulse server;
-- keep command execution disabled unless you explicitly need governed
- remediation;
+- keep the local command-authority profile `monitoring-only` unless you
+ explicitly accept the transitional combined command runtime;
- update from signed release assets rather than arbitrary branch snapshots.
The agent is primarily an outbound reporter to your Pulse server. By default it
@@ -52,12 +52,16 @@ capacity monitoring, for example a log2ram `/var/log` mount. This reports
filesystem capacity and usage metadata. It does not read or transmit file
contents. Local `--disk-exclude` rules still take precedence.
-Command execution is disabled by default. It can be enabled with
-`--enable-commands`, `PULSE_ENABLE_COMMANDS=true`, or the centralized agent
-command setting after enrollment. Leave it disabled for read-only monitoring.
-When enabled, commands still flow through Pulse's command policy and approval
-surfaces instead of silently turning every agent into an unrestricted remote
-shell.
+Fresh installs use a local `monitoring-only` command-authority ceiling and a
+credential without `agent:exec`. Remote configuration cannot promote that
+service. Selecting the advanced legacy combined command profile at install time
+adds `--enable-commands`, records `command-capable`, and issues an execution-
+scoped credential. The root monitoring process can then also accept server
+command requests through the existing policy and approval surfaces. Existing
+unmarked services upgrade as `legacy` during the migration window so upgrades
+do not silently revoke an operator's prior command choice. Agent Doctor shows
+the process privilege, local authority ceiling, and credential execution scope
+separately and warns about mismatches.
Custom numeric sensors are a separate, local configuration boundary. Enabling
them with `--custom-sensors-file` does not enable remote commands and
@@ -139,6 +143,117 @@ user and token.
## Least-Privilege Agent Profile
+For a standard Linux systemd host, the stronger opt-in profile is:
+
+```bash
+install.sh --least-privilege --enable-privileged-helper ...
+```
+
+This profile keeps the networked collector unprivileged and keeps both the
+collector and helper binaries root-owned. Its installer token lives under the
+root-owned `/etc/pulse-agent` directory with `root:pulse-agent` group-read
+access; mutable identity, buffering, and enrolled monitoring-token state remain
+under the collector-owned state directory. The collector is not added to the
+rootful Docker group and cannot enable command execution. Automatic binary
+replacement uses a separate typed transaction: the collector downloads and
+self-tests a signed artifact inside its fixed quarantine, while the helper
+revalidates ownership, digest, ELF shape, and signature before promoting it to
+root-only staging and atomically activating it. A failed process restart asks
+the helper to restore the identity-bound last-known-good binary; there is no
+collector-writable direct-replacement fallback.
+
+Exceptional telemetry crosses `/run/pulse-agent/helper.sock` to a separate
+root process. The socket admits only the `pulse-agent` UID, and the helper has
+no Pulse URL, API token, or network namespace. Its protocol exposes bounded,
+versioned SMART and Proxmox LXC filesystem snapshots, not a shell, executable
+path, device path, VMID, environment, or caller-selected arguments. The helper
+service keeps `PrivateNetwork=true`, `RestrictAddressFamilies=AF_UNIX`,
+`NoNewPrivileges=true`, `ProtectSystem=strict`, and `ProtectHome=true`.
+`PrivateDevices` is intentionally not enabled because SMART needs the host
+block devices. If the helper is missing, incompatible, or rejects a request,
+only the affected telemetry disappears; the collector does not fall back to
+sudo, root, or a broader local command path.
+
+The typed-helper profile cannot be combined with `--grant-smart` or
+`--grant-pct`. Rootful Docker-socket monitoring is also unavailable because
+membership in the Docker group is root-equivalent; API monitoring or a
+separately scoped rootless runtime socket is required instead. The profile is
+currently explicit rather than the installer default. Its inspect, apply, and
+rollback transaction is implemented for Linux systemd, but representative
+live-host migration and helper update staging/activation/rollback exercises,
+container-runtime parity, and appliance qualification are still required
+before the profile can become the general default.
+
+Monitoring never implies remediation. On the supported Linux systemd profile,
+an operator may separately enroll the typed action runner:
+
+```bash
+install.sh --least-privilege --enable-privileged-helper \
+ --enable-action-runner --action-token-file /root/pulse-runner.token ...
+```
+
+Create that token through the authenticated action-runner credential endpoint
+for the exact monitored host; do not reuse the collector token. The installer
+keeps the runner binary, service, credential, health record, and receipts
+root-owned. Disabling or uninstalling the runner leaves monitoring active.
+The safe runner accepts only versioned host update/storage-cleanup, Proxmox
+guest lifecycle, and container lifecycle/update requests. Generic shell,
+`exec`, unrestricted `read_file`, and deploy operations are rejected.
+
+**Settings → Infrastructure → Agent Doctor** presents this boundary as
+evidence, not an inferred security grade. The collector reports whether it is
+root, its service user and local command-authority ceiling, and whether the
+typed helper, SMART helper, and `pct` helper are configured. Pulse separately
+joins the current tenant's token inventory and admitted command sessions to
+show whether the collector credential is known and execution-scoped, whether
+a host-bound runner credential is active, and whether a compatible
+`action-runner` / `typed_actions.v1` session is actually connected. Missing
+evidence remains unknown; collector health never proves remediation readiness.
+
+Runner enrollment is one host at a time. The issuance request must resolve to
+exactly one non-conflicted monitored agent ID and normalized hostname in the
+current tenant. The returned secret is shown once and held only in the open
+Agent Doctor page's memory; it is not written to browser storage, a URL, a
+diagnostic report, or an installer command. The generated handoff first prompts
+for the secret through `/dev/tty` into the root-owned
+`/etc/pulse-agent-runner/token` file with mode `0600`, then passes only that
+file path through `--action-token-file`. Issuing again rotates the credential
+for that tenant/host binding: Pulse durably replaces the previous record as one
+transaction and restores it if persistence fails. A successful rotation makes
+the previous secret invalid, so complete the new token-file handoff before
+expecting a disconnected runner to reconnect.
+
+The existing combined collector command path remains available only as the
+explicit legacy/full-trust migration profile. It is not part of the typed
+helper/runner security claim and will remain until supported command-enabled
+installs have a runner enrollment path and live action-session parity has been
+qualified.
+
+Safe-profile conversion is always deliberate:
+
+```bash
+install.sh --safe-profile-inspect ...
+install.sh --safe-profile-apply ...
+install.sh --safe-profile-rollback ...
+```
+
+Inspection makes no changes. Apply snapshots collector/helper files and
+identity before switching profiles, and rollback restores that snapshot. The
+separately installed action runner is not changed by either operation.
+Inspection reports platform support, the current unit user and groups, ambient
+capabilities, collector-binary owner and mode, enabled provider flags, helper
+and collector-command state, independent runner presence, and the calculated
+typed-helper target and degraded Docker/action differences. Apply is supported
+only on reviewed standard Linux systemd hosts: it retains the monitoring token
+and agent identity, lowers the collector to monitoring-only, removes legacy
+sudo/Docker-group/ambient authority, requires collector health, helper socket
+health, and declared server registration before commit, and automatically
+restores the exact snapshot on failure. Ordinary `--update` preserves the
+installed profile and never performs this migration.
+
+The older `--least-privilege` profile without `--enable-privileged-helper`
+remains available for compatibility. Its trade-offs are documented below.
+
On standard Linux systemd hosts, `install.sh --least-privilege` is a supported
alternative to the root profile. It runs the service as a dedicated
`pulse-agent` system user (nologin shell, owning only its state directory and
@@ -172,9 +287,10 @@ platforms (TrueNAS, Synology, QNAP, Unraid) and non-systemd init systems keep
the root profile; the installer refuses `--least-privilege` there rather than
silently falling back to root.
-`--update` preserves an existing least-privilege profile and its grants
-without the flags being repeated. Uninstall removes the sudoers file and
-helpers; the inert system user is left behind deliberately.
+`--update` preserves either installed least-privilege profile without the flags
+being repeated. Uninstall removes the typed helper socket/service and its
+installer credential directory, or the legacy sudoers file and wrappers; the
+inert system user is left behind deliberately.
## Supply-Chain Boundary
diff --git a/frontend-modern/public/docs/UNIFIED_AGENT.md b/frontend-modern/public/docs/UNIFIED_AGENT.md
index 2c1e1b9e1..7968c0114 100644
--- a/frontend-modern/public/docs/UNIFIED_AGENT.md
+++ b/frontend-modern/public/docs/UNIFIED_AGENT.md
@@ -90,6 +90,58 @@ curl -fsSL http://:7655/install.sh | \
- **Auto-Update**: Automatically updates when a new version is released
- **Multi-Platform**: Linux, macOS, Windows support
+The opt-in Linux typed-helper collector profile keeps auto-update enabled
+without making its root-owned binary writable by the service account. The
+collector downloads and self-tests the signed artifact in a fixed
+collector-owned quarantine; the no-network helper revalidates it, promotes it
+to root-only staging, atomically activates it, and owns identity-bound rollback
+if restart fails. There is no fallback to direct unprivileged replacement.
+This transaction is covered by unit, race, release-build, and installer
+contract tests; live-host restart/health and rollback qualification remains
+required before the profile becomes the general default.
+See [Agent Security](AGENT_SECURITY.md#least-privilege-agent-profile).
+
+On Linux systemd, the safe monitoring profile and remediation lifecycle are
+separate install choices. `--least-privilege --enable-privileged-helper`
+selects the opt-in unprivileged collector and no-network helper. Adding
+`--enable-action-runner --action-token-file ` installs the
+root-owned runner with a separately issued, host-bound action credential.
+`--disable-action-runner` and `--uninstall-action-runner` remove remediation
+without removing the collector. The runner accepts only the documented typed
+host, Proxmox guest, and container operations; shell, generic exec,
+unrestricted file reads, and deploy requests remain forbidden.
+
+Agent Doctor shows the reported collector service user, command-authority
+ceiling, collector credential scope, and typed-helper configuration separately
+from the tenant- and host-bound action-runner credential and connected-session
+state. It does not treat a healthy collector as proof that actions are ready.
+For an eligible Linux systemd host, enrollment returns one credential reveal
+kept only in the open page's memory. Start the generated `/dev/tty` prompt,
+copy and paste the secret into that prompt to write the private root-owned
+token file, and then run the installer command, which carries only
+`--action-token-file ` in its arguments. Re-enrollment atomically
+replaces the prior credential for the same tenant and canonical agent ID; a
+failed persistence write retains the previous credential, while a successful
+rotation invalidates it.
+
+Use `--safe-profile-inspect` to report the current profile and calculated
+differences without changing the host. `--safe-profile-apply` performs the
+explicit collector/helper migration and retains a rollback snapshot;
+`--safe-profile-rollback` restores it. These commands are proven only for
+Linux systemd and fail closed elsewhere. Appliance, non-systemd, Windows, and
+macOS installs remain explicit legacy/full-trust profiles until their own
+runtime and migration boundaries are qualified.
+Inspection includes unit identity/groups, ambient capabilities, binary
+ownership/mode, provider flags, helper/command state, independent runner
+presence, and the expected telemetry/action degradation. Apply preserves the
+collector token and agent identity, commits only after local health, helper
+socket, and server registration checks, and restores the exact prior
+collector/helper profile on failure. It never changes a separately installed
+runner, and ordinary `--update` never implies a profile migration. The secure
+runtime separation remains a proposed hardening lane; the product-wide default
+does not change until representative real Linux hosts pass migration,
+activation, rollback, provider-parity, and action-session qualification.
+
On Linux, the host module automatically checks for `virsh`. When the agent can
open the default libvirt connection read-only, defined domains appear as VM
workloads under that host. Collection uses libvirt's bounded bulk statistics
diff --git a/frontend-modern/src/api/__tests__/agentDiagnostics.test.ts b/frontend-modern/src/api/__tests__/agentDiagnostics.test.ts
index 04e5f3d95..61ddf1a7e 100644
--- a/frontend-modern/src/api/__tests__/agentDiagnostics.test.ts
+++ b/frontend-modern/src/api/__tests__/agentDiagnostics.test.ts
@@ -54,4 +54,32 @@ describe('AgentDiagnosticsAPI', () => {
summary: { total: 0, healthy: 0, warning: 0, critical: 0, removed: 0 },
});
});
+
+ it('issues a separately scoped host-bound action-runner credential', async () => {
+ mockedApiFetchJSON.mockResolvedValueOnce({
+ token: 'runner-secret',
+ tokenId: 'token-1',
+ organizationId: 'org-1',
+ agentId: 'host-1',
+ hostname: 'host-1.local',
+ runtimeRole: 'action-runner',
+ actionCapability: 'typed_actions.v1',
+ });
+
+ await expect(
+ AgentDiagnosticsAPI.issueActionRunnerCredential({
+ agentId: 'host-1',
+ hostname: 'host-1.local',
+ name: 'host-1 action runner',
+ }),
+ ).resolves.toMatchObject({ token: 'runner-secret', agentId: 'host-1' });
+ expect(mockedApiFetchJSON).toHaveBeenCalledWith('/api/agents/action-runner/credential', {
+ method: 'POST',
+ body: JSON.stringify({
+ agentId: 'host-1',
+ hostname: 'host-1.local',
+ name: 'host-1 action runner',
+ }),
+ });
+ });
});
diff --git a/frontend-modern/src/api/agentDiagnostics.ts b/frontend-modern/src/api/agentDiagnostics.ts
index d13c5781c..df1eeb021 100644
--- a/frontend-modern/src/api/agentDiagnostics.ts
+++ b/frontend-modern/src/api/agentDiagnostics.ts
@@ -54,8 +54,36 @@ export interface AgentFleetDiagnosticPrivilege {
commandAuthority?: 'monitoring-only' | 'command-capable' | 'legacy' | string;
credentialKnown?: boolean;
credentialExec?: boolean;
+ typedHelper?: boolean;
smartctlHelper?: boolean;
pctHelper?: boolean;
+ actionRunnerCredentialIssued?: boolean;
+ actionRunnerCredentialActive?: boolean;
+ actionRunnerRuntimeRole?: string;
+ actionRunnerCapability?: string;
+ actionRunnerBindingVersion?: string;
+ actionRunnerConnected?: boolean;
+ actionRunnerVersion?: string;
+ actionRunnerConnectedAt?: number;
+ actionRunnerReceiptProtocol?: number;
+ actionRunnerPreflightProtocol?: number;
+ actionRunnerDockerObservationProtocol?: number;
+}
+
+export interface ActionRunnerCredentialRequest {
+ agentId: string;
+ hostname: string;
+ name?: string;
+}
+
+export interface ActionRunnerCredentialResponse {
+ token: string;
+ tokenId: string;
+ organizationId: string;
+ agentId: string;
+ hostname: string;
+ runtimeRole: 'action-runner';
+ actionCapability: 'typed_actions.v1';
}
export interface AgentFleetAgentDiagnostic {
@@ -130,4 +158,13 @@ export class AgentDiagnosticsAPI {
agents: response.agents ?? [],
};
}
+
+ static issueActionRunnerCredential(
+ request: ActionRunnerCredentialRequest,
+ ): Promise {
+ return apiFetchJSON('/api/agents/action-runner/credential', {
+ method: 'POST',
+ body: JSON.stringify(request),
+ });
+ }
}
diff --git a/frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx b/frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx
index 0a2f8bcae..d35a80dd0 100644
--- a/frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx
+++ b/frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx
@@ -15,12 +15,17 @@ import { copyToClipboard } from '@/utils/clipboard';
import { formatRelativeTime } from '@/utils/format';
import { notificationStore } from '@/stores/notifications';
import { MonitoringAPI } from '@/api/monitoring';
+import { AgentDiagnosticsAPI } from '@/api/agentDiagnostics';
import {
getUnifiedAgentClipboardCopyErrorMessage,
getUnifiedAgentClipboardCopySuccessMessage,
} from '@/utils/unifiedAgentInventoryPresentation';
import {
formatInfrastructureAgentDoctorReport,
+ buildActionRunnerInstallCommand,
+ buildActionRunnerTokenFileCommand,
+ buildSafeCollectorApplyCommand,
+ buildSafeCollectorInspectCommand,
getInfrastructureAgentDoctorUninstallHandoff,
summarizeInfrastructureAgentDoctorTargets,
type InfrastructureAgentDoctorStatus,
@@ -192,6 +197,61 @@ export const InfrastructureAgentDoctorPage: Component
+ >(new Set());
+ const [runnerCredentialReveal, setRunnerCredentialReveal] = createSignal<{
+ targetKey: string;
+ token: string;
+ } | null>(null);
+
+ const issueActionRunnerCredential = async (target: InfrastructureAgentDoctorTarget) => {
+ if (
+ !target.actionRunnerCredentialEligible ||
+ !target.actionRunnerAgentId ||
+ !target.actionRunnerHostname
+ ) {
+ return;
+ }
+ setRunnerCredentialReveal(null);
+ setRunnerCredentialPendingKey(target.key);
+ try {
+ const response = await AgentDiagnosticsAPI.issueActionRunnerCredential({
+ agentId: target.actionRunnerAgentId,
+ hostname: target.actionRunnerHostname,
+ name: `${target.displayName} action runner`,
+ });
+ const normalizedHostname = (value: string) => value.trim().toLowerCase().replace(/\.+$/, '');
+ if (
+ response.agentId !== target.actionRunnerAgentId ||
+ normalizedHostname(response.hostname) !== normalizedHostname(target.actionRunnerHostname) ||
+ response.runtimeRole !== 'action-runner' ||
+ response.actionCapability !== 'typed_actions.v1' ||
+ !response.token
+ ) {
+ throw new Error(
+ 'Pulse returned an unexpected action-runner credential identity or authority.',
+ );
+ }
+ setRunnerCredentialReveal({ targetKey: target.key, token: response.token });
+ setIssuedRunnerCredentialKeys((previous) => new Set(previous).add(target.key));
+ notificationStore.success('Action-runner credential issued. Save it now. It is shown once.');
+ try {
+ await props.onRetryDiagnostics?.();
+ } catch {
+ notificationStore.error(
+ 'The credential was issued, but Pulse could not refresh its runner posture.',
+ );
+ }
+ } catch (error) {
+ notificationStore.error(
+ error instanceof Error ? error.message : 'Failed to issue action-runner credential.',
+ );
+ } finally {
+ setRunnerCredentialPendingKey('');
+ }
+ };
const allowReenroll = async (target: InfrastructureAgentDoctorTarget) => {
const separator = target.connectionId.indexOf(':');
const type = separator > 0 ? target.connectionId.slice(0, separator) : '';
@@ -552,6 +612,205 @@ export const InfrastructureAgentDoctorPage: Component
+ This secret is held only in this page's memory and cannot be
+ recovered after you clear or leave it. It is never placed in a
+ URL, diagnostic report, installer command, or process
+ argument.
+