Hand off host-local uninstall commands for removed agents in Agent Doctor

Removed targets previously dead-ended with no next step. Their row
expansion now offers the host-local uninstall command, resolved from the
retained diagnostic identity via the strict platform resolver, or both
labeled Linux-family and Windows commands when the platform is unknown
(the current wire reality, since removed records retain no platform).
The handoff carries the diagnostic's agent id and hostname as uninstall
identity flags and states the command runs on the affected host, not
from Pulse. The governed uninstall builders now accept a narrowed
AgentUninstallIdentity view so surfaces without a ledger-backed
inventory row reuse the same transport.
This commit is contained in:
rcourtman
2026-07-22 10:56:04 +01:00
parent c87ad1cf26
commit 4fc86ec187
6 changed files with 210 additions and 10 deletions
@@ -1990,8 +1990,19 @@ only workload telemetry, e.g. Docker-only or Kubernetes-only agents) are now
appended as diagnostics-only targets, honoring the scoped-agent filter, so a appended as diagnostics-only targets, honoring the scoped-agent filter, so a
critical workload-only agent can no longer vanish from the fleet view. critical workload-only agent can no longer vanish from the fleet view.
Diagnostics-only rows render the diagnostic's status, reasons, and evidence Diagnostics-only rows render the diagnostic's status, reasons, and evidence
but offer no host-local command (there is no ledger connection to derive one but offer no host-local update command (there is no ledger connection to
from). Page presentation follows the same honesty rule: the derive an update from). Removed rows are the deliberate exception in the
uninstall direction: a removed agent's one remaining next step is host-side
cleanup, so a removed target resolves its command platform from the retained
diagnostic identity through the strict platform resolver and its row
expansion hands off the host-local uninstall command
(`getInfrastructureAgentDoctorUninstallHandoff`) — the single matching
platform command when the identity resolves, or both explicitly labeled
Linux-family and Windows commands when it does not, never one guessed
executable. The handoff carries the diagnostic's agent id and hostname as
uninstall identity flags, and its copy states the command runs on the
affected host itself, not from Pulse. Page presentation follows the same
honesty rule: the
"Target" column renders a version only when one is actually published, the "Target" column renders a version only when one is actually published, the
host-local command explainer renders only when at least one row offers a host-local command explainer renders only when at least one row offers a
command, and the summary chips list only non-zero status counts. The summary command, and the summary chips list only non-zero status counts. The summary
@@ -2001,8 +2012,8 @@ also offers plain-text diagnostic reports (fleet-level over the currently
visible rows and per-agent from a row expansion) via visible rows and per-agent from a row expansion) via
`formatInfrastructureAgentDoctorReport`; reports carry status, versions, `formatInfrastructureAgentDoctorReport`; reports carry status, versions,
last-seen, reasons, identity evidence, and non-command repair actions, and last-seen, reasons, identity evidence, and non-command repair actions, and
must never embed host-local update commands because those can carry install must never embed host-local update or uninstall commands because those can
tokens. carry install tokens.
### Governed action readiness remains outside agent lifecycle authority ### Governed action readiness remains outside agent lifecycle authority
@@ -5972,6 +5972,15 @@ canonical identity when inventory already has it: shell uninstall payloads must
carry `--agent-id`, and PowerShell uninstall payloads must carry carry `--agent-id`, and PowerShell uninstall payloads must carry
`PULSE_AGENT_ID`, so deregistration targets the intended governed agent record `PULSE_AGENT_ID`, so deregistration targets the intended governed agent record
instead of depending on local fallback files or hostname lookup. instead of depending on local fallback files or hostname lookup.
That uninstall identity seam is deliberately narrower than the unified
inventory row: the governed uninstall builders in
`frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx`
accept a caller-supplied identity view carrying only
`agentActionId`/`agentId`/`hostname` (the exported `AgentUninstallIdentity`
pick), so lifecycle surfaces without a ledger-backed inventory row — Agent
Doctor's removed-agent diagnostics handoff — bind copied uninstall payloads to
the retained diagnostic identity through the same governed builders instead of
inventing a second uninstall transport.
The same identity-preservation contract applies to copied upgrade transport: The same identity-preservation contract applies to copied upgrade transport:
shell upgrade payloads must carry `--agent-id` and `--hostname`, and shell upgrade payloads must carry `--agent-id` and `--hostname`, and
PowerShell upgrade payloads must carry `PULSE_AGENT_ID` and `PULSE_HOSTNAME`, PowerShell upgrade payloads must carry `PULSE_AGENT_ID` and `PULSE_HOSTNAME`,
@@ -18,6 +18,7 @@ import {
} from '@/utils/unifiedAgentInventoryPresentation'; } from '@/utils/unifiedAgentInventoryPresentation';
import { import {
formatInfrastructureAgentDoctorReport, formatInfrastructureAgentDoctorReport,
getInfrastructureAgentDoctorUninstallHandoff,
summarizeInfrastructureAgentDoctorTargets, summarizeInfrastructureAgentDoctorTargets,
type InfrastructureAgentDoctorStatus, type InfrastructureAgentDoctorStatus,
type InfrastructureAgentDoctorTarget, type InfrastructureAgentDoctorTarget,
@@ -373,6 +374,8 @@ export const InfrastructureAgentDoctorPage: Component<InfrastructureAgentDoctorP
(target.diagnostic?.repairActions ?? []).filter( (target.diagnostic?.repairActions ?? []).filter(
(action) => action.code !== 'copy_upgrade_command', (action) => action.code !== 'copy_upgrade_command',
); );
const uninstallHandoff = () =>
getInfrastructureAgentDoctorUninstallHandoff(target);
const expanded = () => isExpanded(target); const expanded = () => isExpanded(target);
return ( return (
@@ -526,6 +529,50 @@ export const InfrastructureAgentDoctorPage: Component<InfrastructureAgentDoctorP
</Show> </Show>
</Show> </Show>
<Show when={uninstallHandoff()}>
{(handoff) => (
<div class="space-y-2">
<p class="text-xs text-muted">
This agent was removed from Pulse, but the agent software may
still be installed on its host. Finish detaching it by running
the uninstall command on the affected host itself. Pulse does
not run commands remotely.
</p>
<For each={handoff().commands}>
{(entry) => (
<div>
<div class="mb-1 text-[11px] font-medium uppercase tracking-wide text-muted">
{entry.label}
</div>
<div class="relative">
<CommandCopyButton
onClick={() =>
void copyCommand(
operations.getPlatformUninstallCommand(
entry.platform,
handoff().identity,
),
)
}
title="Copy host-local agent uninstall command"
label={`Copy ${entry.label} uninstall command for ${target.displayName}`}
/>
<pre class="overflow-x-auto rounded-md bg-base p-3 pr-12 text-xs text-base-content">
<code>
{operations.getPlatformUninstallCommand(
entry.platform,
handoff().identity,
)}
</code>
</pre>
</div>
</div>
)}
</For>
</div>
)}
</Show>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
@@ -6,6 +6,7 @@ import {
collectInfrastructureAgentDoctorTargets, collectInfrastructureAgentDoctorTargets,
diagnosticConnectionID, diagnosticConnectionID,
formatInfrastructureAgentDoctorReport, formatInfrastructureAgentDoctorReport,
getInfrastructureAgentDoctorUninstallHandoff,
resolveKnownAgentCommandPlatform, resolveKnownAgentCommandPlatform,
type InfrastructureAgentDoctorTarget, type InfrastructureAgentDoctorTarget,
} from '../infrastructureAgentUpdateCommandsModel'; } from '../infrastructureAgentUpdateCommandsModel';
@@ -278,6 +279,90 @@ describe('Agent Doctor model', () => {
).not.toContain('removed'); ).not.toContain('removed');
}); });
it('hands removed targets an uninstall command scoped to their reported platform', () => {
const removedWindows = diagnosticFixture({
connectionId: 'agent:removed-win',
rowKey: 'removed-win',
id: 'removed-win',
agentId: 'removed-win-agent',
name: 'removed-win',
hostname: 'win-host',
platform: 'Windows Server 2022',
status: 'removed',
});
const removedLinux = diagnosticFixture({
connectionId: 'agent:removed-deb',
rowKey: 'removed-deb',
id: 'removed-deb',
agentId: 'removed-deb-agent',
name: 'removed-deb',
hostname: 'deb-host',
platform: 'debian',
status: 'removed',
});
const targets = collectInfrastructureAgentDoctorTargets({
rows: [],
diagnostics: [removedWindows, removedLinux],
diagnosticsAvailable: true,
});
const windowsTarget = targets.find((target) => target.displayName === 'removed-win');
const linuxTarget = targets.find((target) => target.displayName === 'removed-deb');
expect(windowsTarget?.commandPlatform).toBe('windows');
expect(getInfrastructureAgentDoctorUninstallHandoff(windowsTarget!)).toEqual({
identity: { agentId: 'removed-win-agent', hostname: 'win-host' },
commands: [{ label: 'Windows PowerShell', platform: 'windows' }],
});
expect(linuxTarget?.commandPlatform).toBe('linux');
expect(getInfrastructureAgentDoctorUninstallHandoff(linuxTarget!)).toEqual({
identity: { agentId: 'removed-deb-agent', hostname: 'deb-host' },
commands: [{ label: 'Linux / macOS / FreeBSD', platform: 'linux' }],
});
});
it('offers both labeled uninstall families when a removed agent has no recognized platform', () => {
const removed = diagnosticFixture({
connectionId: 'agent:removed-host',
rowKey: 'removed-host',
id: 'removed-host',
agentId: undefined,
name: 'removed-host',
hostname: undefined,
status: 'removed',
});
const [target] = collectInfrastructureAgentDoctorTargets({
rows: [],
diagnostics: [removed],
diagnosticsAvailable: true,
});
expect(target.commandPlatform).toBeNull();
expect(getInfrastructureAgentDoctorUninstallHandoff(target)).toEqual({
identity: { agentId: 'removed-host', hostname: undefined },
commands: [
{ label: 'Linux / macOS / FreeBSD', platform: 'linux' },
{ label: 'Windows PowerShell', platform: 'windows' },
],
});
});
it('keeps the uninstall handoff off non-removed targets', () => {
const connection = connectionFixture();
const [target] = collectInfrastructureAgentDoctorTargets({
rows: [rowFixture(connection)],
connections: [connection],
diagnostics: [diagnosticFixture()],
diagnosticsAvailable: true,
targetVersion: '6.2.0',
});
expect(target.status).not.toBe('removed');
expect(getInfrastructureAgentDoctorUninstallHandoff(target)).toBeNull();
});
it('keeps integration-monitored machines out of the doctor', () => { it('keeps integration-monitored machines out of the doctor', () => {
const agent = connectionFixture({ agentUpdateAvailable: false }); const agent = connectionFixture({ agentUpdateAvailable: false });
const esxi = connectionFixture({ const esxi = connectionFixture({
@@ -599,7 +599,7 @@ const diagnosticOnlyDoctorTarget = (
reasons: diagnostic.reasons ?? [], reasons: diagnostic.reasons ?? [],
evidence: evidenceFor(undefined, diagnostic), evidence: evidenceFor(undefined, diagnostic),
needsUpdate: false, needsUpdate: false,
commandPlatform: null, commandPlatform: resolveKnownAgentCommandPlatform(diagnostic.platform),
profileLabel: diagnostic.profileName?.trim() || diagnostic.profileId?.trim() || undefined, profileLabel: diagnostic.profileName?.trim() || diagnostic.profileId?.trim() || undefined,
profileVersionLabel: diagnostic.profileVersion profileVersionLabel: diagnostic.profileVersion
? `Expected v${diagnostic.profileVersion} · deployed v${diagnostic.deployedProfileVersion || 0}` ? `Expected v${diagnostic.profileVersion} · deployed v${diagnostic.deployedProfileVersion || 0}`
@@ -672,6 +672,46 @@ export const collectInfrastructureAgentDoctorTargets = ({
); );
}; };
export type InfrastructureAgentDoctorUninstallHandoff = {
identity: { agentId?: string; hostname?: string };
commands: { label: string; platform: AgentCommandPlatform }[];
};
const UNINSTALL_SHELL_LABEL = 'Linux / macOS / FreeBSD';
const UNINSTALL_WINDOWS_LABEL = 'Windows PowerShell';
/**
* Removed targets keep one legitimate next step: uninstalling the agent on
* the host itself. When the retained diagnostic identity resolves to a known
* platform the handoff carries that single command; when it does not, both
* host families are offered explicitly labeled rather than guessing one.
*/
export const getInfrastructureAgentDoctorUninstallHandoff = (
target: InfrastructureAgentDoctorTarget,
): InfrastructureAgentDoctorUninstallHandoff | null => {
if (target.status !== 'removed') return null;
const identity = {
agentId: target.diagnostic?.agentId?.trim() || target.diagnostic?.id?.trim() || undefined,
hostname: target.diagnostic?.hostname?.trim() || undefined,
};
if (target.commandPlatform === 'windows') {
return { identity, commands: [{ label: UNINSTALL_WINDOWS_LABEL, platform: 'windows' }] };
}
if (target.commandPlatform) {
return {
identity,
commands: [{ label: UNINSTALL_SHELL_LABEL, platform: target.commandPlatform }],
};
}
return {
identity,
commands: [
{ label: UNINSTALL_SHELL_LABEL, platform: 'linux' },
{ label: UNINSTALL_WINDOWS_LABEL, platform: 'windows' },
],
};
};
export const summarizeInfrastructureAgentDoctorTargets = ( export const summarizeInfrastructureAgentDoctorTargets = (
targets: readonly InfrastructureAgentDoctorTarget[], targets: readonly InfrastructureAgentDoctorTarget[],
) => ({ ) => ({
@@ -20,6 +20,13 @@ import {
export type InfrastructureOperationsStateOptions = InfrastructureInstallStateOptions; export type InfrastructureOperationsStateOptions = InfrastructureInstallStateOptions;
// Uninstall commands only need the host identity flags, so callers without a
// full inventory row (e.g. Agent Doctor's removed diagnostics) can hand one in.
export type AgentUninstallIdentity = Pick<
UnifiedAgentRow,
'agentActionId' | 'agentId' | 'hostname'
>;
export const useInfrastructureOperationsState = ( export const useInfrastructureOperationsState = (
options: InfrastructureOperationsStateOptions = {}, options: InfrastructureOperationsStateOptions = {},
) => { ) => {
@@ -69,9 +76,10 @@ export const useInfrastructureOperationsState = (
return installState.currentToken(); return installState.currentToken();
}; };
const getCanonicalUninstallAgentId = (row?: UnifiedAgentRow) => const getCanonicalUninstallAgentId = (row?: AgentUninstallIdentity) =>
row?.agentActionId?.trim() || row?.agentId?.trim() || ''; row?.agentActionId?.trim() || row?.agentId?.trim() || '';
const getCanonicalUninstallHostname = (row?: UnifiedAgentRow) => row?.hostname?.trim() || ''; const getCanonicalUninstallHostname = (row?: AgentUninstallIdentity) =>
row?.hostname?.trim() || '';
const getCanonicalConnectionAgentId = (connection: Connection) => { const getCanonicalConnectionAgentId = (connection: Connection) => {
if (connection.type !== 'agent') return ''; if (connection.type !== 'agent') return '';
const id = connection.id.trim(); const id = connection.id.trim();
@@ -87,7 +95,7 @@ export const useInfrastructureOperationsState = (
const getConnectionUpgradePlatform = (connection: Connection): AgentPlatform => const getConnectionUpgradePlatform = (connection: Connection): AgentPlatform =>
resolveAgentCommandPlatform(connection.agentIdentity?.platform); resolveAgentCommandPlatform(connection.agentIdentity?.platform);
const getUninstallCommand = (row?: UnifiedAgentRow) => { const getUninstallCommand = (row?: AgentUninstallIdentity) => {
const url = installState.selectedAgentUrl(); const url = installState.selectedAgentUrl();
const token = resolvedCommandToken(); const token = resolvedCommandToken();
const insecure = getInsecureFlag(url); const insecure = getInsecureFlag(url);
@@ -102,7 +110,7 @@ export const useInfrastructureOperationsState = (
); );
}; };
const getWindowsUninstallCommand = (row?: UnifiedAgentRow) => { const getWindowsUninstallCommand = (row?: AgentUninstallIdentity) => {
const url = installState.selectedAgentUrl(); const url = installState.selectedAgentUrl();
const token = resolvedCommandToken(); const token = resolvedCommandToken();
const transportEnv = getPowerShellTransportEnv(); const transportEnv = getPowerShellTransportEnv();
@@ -123,7 +131,7 @@ export const useInfrastructureOperationsState = (
return `${prefix}$env:PULSE_URL="${powerShellQuote(url)}"; $env:PULSE_UNINSTALL="true"; ${buildPowerShellInstallScriptBootstrap(url)}`; return `${prefix}$env:PULSE_URL="${powerShellQuote(url)}"; $env:PULSE_UNINSTALL="true"; ${buildPowerShellInstallScriptBootstrap(url)}`;
}; };
const getPlatformUninstallCommand = (platform: AgentPlatform, row?: UnifiedAgentRow) => { const getPlatformUninstallCommand = (platform: AgentPlatform, row?: AgentUninstallIdentity) => {
if (platform === 'windows') { if (platform === 'windows') {
return getWindowsUninstallCommand(row); return getWindowsUninstallCommand(row);
} }