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

View file

@ -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
critical workload-only agent can no longer vanish from the fleet view.
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
from). Page presentation follows the same honesty rule: the
but offer no host-local update command (there is no ledger connection to
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
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
@ -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
`formatInfrastructureAgentDoctorReport`; reports carry status, versions,
last-seen, reasons, identity evidence, and non-command repair actions, and
must never embed host-local update commands because those can carry install
tokens.
must never embed host-local update or uninstall commands because those can
carry install tokens.
### Governed action readiness remains outside agent lifecycle authority

View file

@ -5972,6 +5972,15 @@ canonical identity when inventory already has it: shell uninstall payloads must
carry `--agent-id`, and PowerShell uninstall payloads must carry
`PULSE_AGENT_ID`, so deregistration targets the intended governed agent record
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:
shell upgrade payloads must carry `--agent-id` and `--hostname`, and
PowerShell upgrade payloads must carry `PULSE_AGENT_ID` and `PULSE_HOSTNAME`,

View file

@ -18,6 +18,7 @@ import {
} from '@/utils/unifiedAgentInventoryPresentation';
import {
formatInfrastructureAgentDoctorReport,
getInfrastructureAgentDoctorUninstallHandoff,
summarizeInfrastructureAgentDoctorTargets,
type InfrastructureAgentDoctorStatus,
type InfrastructureAgentDoctorTarget,
@ -373,6 +374,8 @@ export const InfrastructureAgentDoctorPage: Component<InfrastructureAgentDoctorP
(target.diagnostic?.repairActions ?? []).filter(
(action) => action.code !== 'copy_upgrade_command',
);
const uninstallHandoff = () =>
getInfrastructureAgentDoctorUninstallHandoff(target);
const expanded = () => isExpanded(target);
return (
@ -526,6 +529,50 @@ export const InfrastructureAgentDoctorPage: Component<InfrastructureAgentDoctorP
</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
type="button"
variant="outline"

View file

@ -6,6 +6,7 @@ import {
collectInfrastructureAgentDoctorTargets,
diagnosticConnectionID,
formatInfrastructureAgentDoctorReport,
getInfrastructureAgentDoctorUninstallHandoff,
resolveKnownAgentCommandPlatform,
type InfrastructureAgentDoctorTarget,
} from '../infrastructureAgentUpdateCommandsModel';
@ -278,6 +279,90 @@ describe('Agent Doctor model', () => {
).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', () => {
const agent = connectionFixture({ agentUpdateAvailable: false });
const esxi = connectionFixture({

View file

@ -599,7 +599,7 @@ const diagnosticOnlyDoctorTarget = (
reasons: diagnostic.reasons ?? [],
evidence: evidenceFor(undefined, diagnostic),
needsUpdate: false,
commandPlatform: null,
commandPlatform: resolveKnownAgentCommandPlatform(diagnostic.platform),
profileLabel: diagnostic.profileName?.trim() || diagnostic.profileId?.trim() || undefined,
profileVersionLabel: diagnostic.profileVersion
? `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 = (
targets: readonly InfrastructureAgentDoctorTarget[],
) => ({

View file

@ -20,6 +20,13 @@ import {
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 = (
options: InfrastructureOperationsStateOptions = {},
) => {
@ -69,9 +76,10 @@ export const useInfrastructureOperationsState = (
return installState.currentToken();
};
const getCanonicalUninstallAgentId = (row?: UnifiedAgentRow) =>
const getCanonicalUninstallAgentId = (row?: AgentUninstallIdentity) =>
row?.agentActionId?.trim() || row?.agentId?.trim() || '';
const getCanonicalUninstallHostname = (row?: UnifiedAgentRow) => row?.hostname?.trim() || '';
const getCanonicalUninstallHostname = (row?: AgentUninstallIdentity) =>
row?.hostname?.trim() || '';
const getCanonicalConnectionAgentId = (connection: Connection) => {
if (connection.type !== 'agent') return '';
const id = connection.id.trim();
@ -87,7 +95,7 @@ export const useInfrastructureOperationsState = (
const getConnectionUpgradePlatform = (connection: Connection): AgentPlatform =>
resolveAgentCommandPlatform(connection.agentIdentity?.platform);
const getUninstallCommand = (row?: UnifiedAgentRow) => {
const getUninstallCommand = (row?: AgentUninstallIdentity) => {
const url = installState.selectedAgentUrl();
const token = resolvedCommandToken();
const insecure = getInsecureFlag(url);
@ -102,7 +110,7 @@ export const useInfrastructureOperationsState = (
);
};
const getWindowsUninstallCommand = (row?: UnifiedAgentRow) => {
const getWindowsUninstallCommand = (row?: AgentUninstallIdentity) => {
const url = installState.selectedAgentUrl();
const token = resolvedCommandToken();
const transportEnv = getPowerShellTransportEnv();
@ -123,7 +131,7 @@ export const useInfrastructureOperationsState = (
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') {
return getWindowsUninstallCommand(row);
}