Repair rejected agent credentials safely

This commit is contained in:
rcourtman
2026-08-09 00:51:38 +01:00
parent 4e1d2f6d5d
commit 63a0adf9ac
19 changed files with 850 additions and 110 deletions
@@ -701,6 +701,23 @@ installer path. The `agentDoctor` route key is canonical; the older
Doctor must not replace the canonical `/api/connections` fleet projection used
by Infrastructure.
Authentication repair is a distinct lifecycle handoff, not a successful
upgrade. When a reported host still names a token record that is absent from
the server inventory or has expired, Agent Doctor must raise a critical
credential reason and may offer `repair_authentication` only for a recognized,
unambiguous local runtime. The copied Unix repair command mints a fresh scoped
agent credential and transports it through an ephemeral `--token-file`; an
ordinary version-only Unix update stays tokenless and recovers the installed
credential from installer-owned state. If two different host agent IDs report
the same non-empty machine ID and equivalent hostname, both installations stay
visible as distinct fleet rows with peer identity/version evidence. Pulse must
not collapse them into one apparent agent or choose one generic local service:
authentication and upgrade handoffs fail closed until the duplicate
installation is resolved or an installation-specific path exists. Finally,
local process health cannot override server authentication: a definitive
registration 401/403 makes `scripts/install.sh` complete with the non-zero
`auth_rejected` result instead of printing installation or upgrade success.
Agent lifecycle and fleet-operation surfaces may consume
`POST /api/actions/plan` for resource capability planning, but the action plan
contract remains API-owned through `internal/api/actions.go`,
@@ -6180,6 +6180,21 @@ must tolerate absent optional fields and preserve `/api/connections` fallback
rows. Repair objects describe whether an existing local handoff is supported
and for which normalized platform; they are not executable commands or
authorization grants.
The canonical connections projection must validate a host's non-empty
`TokenID` against the current API-token inventory. Missing records project
`fleet.credentialStatus="invalid"` and credential-health
`status/rotation="invalid"`; expired records project invalid top-level status
with expired health and rotation. Agent Doctor preserves those ledger facts
even when its structured snapshot lags. Structured diagnostics add the stable
`agent_credential_missing`, `agent_credential_expired`, and
`duplicate_host_agent_installation` reason codes plus the
`repair_authentication` handoff. The browser may render a fresh scoped
token-file repair only when that action is supported. A duplicate-installation
reason always blocks generic repair command rendering, while retaining each
connection row and its peer evidence so UI composition never selects one stale
identity as the machine's sole agent. When the token inventory is deliberately
unavailable, including synthetic mock-ledger projection, the API preserves the
prior unknown-compatible credential state instead of inventing invalidity.
That same shared infrastructure-settings boundary also owns install-profile
semantics surfaced by
`frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx`:
@@ -3058,6 +3058,15 @@ gets skipped the time it is real. Token-bearing
copy-paste commands must pass credentials through ephemeral `--token-file`
transport and leave the installed service configured with the persistent
runtime token file, never a raw `--token` process argument.
The same rule governs credential repair after a server restore, token
revocation, or expiry: the Unix repair handoff must run the normal installer
preflight, pass the fresh scoped credential through a temporary mode-0600 token
file, and invoke update mode without exposing the token in the agent service
arguments. Installer completion is authenticated completion, not merely local
process health. When the post-start registration lookup returns 401 or 403,
`scripts/install.sh` must emit the structured `auth_rejected` completion and
exit 18; it must not print the normal install/upgrade success message or report
the Proxmox registration outcome as though the agent were enrolled.
That Windows installer-owned state must also be cleared after successful
PowerShell uninstall, so a removed installation does not leave stale ProgramData
identity or transport continuity behind for later lifecycle commands.
@@ -2594,6 +2594,20 @@ avoids a duplicated distro allowlist, so long-tail distributions such as
Mageia receive the same Linux repair handoff as Ubuntu or Debian. Unknown
updater states remain explicit warnings, and unverified FreeBSD/pfSense
installer state still fails closed for upgrade-command support.
Credential truth is derived from the current server token inventory, not the
last successful heartbeat alone. A live host whose reported `TokenID` no
longer resolves emits `agent_credential_missing`; a resolved but expired record
emits `agent_credential_expired`; both are critical and expose a bounded
authentication-repair handoff only when the runtime family is safely known.
Monitoring also compares live host-agent generations without merging them: two
different host IDs with the same non-empty machine ID and equivalent hostname
each receive `duplicate_host_agent_installation` plus bounded peer evidence.
That reason disables generic upgrade and authentication handoffs, because the
server cannot infer which co-installed local service an operator's command
would mutate. Distinct rows are retained so Agent Doctor does not disguise two
installations as one healthy machine. Mock mode has no authoritative token
inventory for its synthetic hosts and therefore must not turn fixture token IDs
into missing-credential incidents.
`internal/fleethealth/agent_test.go` and
`internal/monitoring/agent_fleet_doctor_test.go` are the focused runtime proofs.
@@ -183,6 +183,16 @@ states do not change the underlying storage evidence or active lifecycle.
## Extension Points
Agent-token validation added to `internal/api/connections_aggregator.go` and
its runtime input assembly in `internal/api/connections_alerts.go` is
fleet-governance evidence only. A missing, revoked, or expired host-agent
credential may mark that agent connection invalid and drive a local
authentication-repair handoff, but storage/recovery consumers must not
reinterpret that credential state as backup failure, protection drift,
recovery-point loss, restore readiness, datastore health, or provider
identity. Token inventory that is unavailable or deliberately synthetic stays
unknown rather than fabricating storage or agent incidents.
Docker / Podman report ingress under `internal/api/docker_agents.go` is outside
storage/recovery ownership even though storage/recovery routes share the broad
`internal/api/` extension boundary. Encoded/decoded report ceilings,
+19 -49
View File
@@ -1,50 +1,25 @@
{
"version": 1,
"base_sha": "7d95a707bcedd82c48158d48e1d203d2c8ea9d07",
"verified_at": "2026-08-08T15:41:56Z",
"base_sha": "4e1d2f6d5dc0819cba4cd3549f82bd87c2e99296",
"verified_at": "2026-08-08T23:45:58Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/App.tsx",
"frontend-modern/src/api/settings.ts",
"frontend-modern/src/components/BusinessEstateCard.tsx",
"frontend-modern/src/components/Settings/ProLicensePanel.tsx",
"frontend-modern/src/components/Settings/settingsNavCatalog.ts",
"frontend-modern/src/components/Settings/settingsNavVisibility.ts",
"frontend-modern/src/components/Settings/settingsNavigationModel.ts",
"frontend-modern/src/components/Settings/useProLicensePanelState.ts",
"frontend-modern/src/components/Settings/useSettingsAccess.ts",
"frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx",
"frontend-modern/src/features/patrol/patrolAutonomyAvailability.ts",
"frontend-modern/src/stores/sessionCapabilities.ts",
"frontend-modern/src/types/config.ts",
"frontend-modern/src/utils/pricingHandoff.ts"
"frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx",
"frontend-modern/src/components/Settings/infrastructureAgentUpdateCommandsModel.ts",
"frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx"
],
"content_sha256": {
"frontend-modern/src/App.tsx": "cf12ab5e6b1e96d1bdf7da889db49b88f4c3161fb60fa9b879400039b361f84c",
"frontend-modern/src/api/settings.ts": "5347422cc84eb13f212e196cddaa45515446b445858ff95f9d1a6ba6810db6f7",
"frontend-modern/src/components/BusinessEstateCard.tsx": "deleted",
"frontend-modern/src/components/Settings/ProLicensePanel.tsx": "be6955b43cc09cd2bca41cdf3a93c2ebe2c6c95c8a893b777df6d9bae7d35fd1",
"frontend-modern/src/components/Settings/settingsNavCatalog.ts": "a95ed0a83af7ba54755928b2fb4955481780240c03351f6b850f3b31d696dc5e",
"frontend-modern/src/components/Settings/settingsNavVisibility.ts": "05407c809e71724694ba39a01288d3580bbe3c35ab7c5dee502ed0ab31364a13",
"frontend-modern/src/components/Settings/settingsNavigationModel.ts": "94f00fe53960b3d7fda42ad7b226197e9fddf5b055cdda80615c75274f464ea0",
"frontend-modern/src/components/Settings/useProLicensePanelState.ts": "7559445debf9afdd86d2e3c5f15a22e81f3eacc4e5bd185c54fe1fc586933f41",
"frontend-modern/src/components/Settings/useSettingsAccess.ts": "42c586bdc3a5f91fdcd8c120bf65e4bd7105d5cc948defc6784ccf389ff631b4",
"frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx": "de74125176e6d7421cec5717c963cd8e992f0ef0f11bf9304bb7b75c6f844658",
"frontend-modern/src/features/patrol/patrolAutonomyAvailability.ts": "7f68482c556ef0164770ac81156a38c6dd3ef600bfa567240cf5ffaf0b517784",
"frontend-modern/src/stores/sessionCapabilities.ts": "8e7557152025a5bdd99a8072b6283c4d3ec3c21ebbeab4f4464ee10308fa9e60",
"frontend-modern/src/types/config.ts": "86778a683f7f259bb3eb0b47586ac177e82a953955538868cec25010585fd5ec",
"frontend-modern/src/utils/pricingHandoff.ts": "d7b863c45a6f8d8a991e8aad99e03869aa50bb3ad525c9ab22a445c1607b4d4d"
"frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx": "6e4afa78ae0cfda3344573174893b80a3930d62e1f3bf2ef9c3288331c18b114",
"frontend-modern/src/components/Settings/infrastructureAgentUpdateCommandsModel.ts": "b6fee8ae8f5fcb8f1a29e561b7ec66845e1183f26888541c12dd1a2a0b57d2a2",
"frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx": "6abfe2d91ed052f968ed45952792660d13d4dc469926a499b3929e0b4c994510"
},
"routes": [
"/settings/security-roles",
"/settings/pulse-intelligence/billing/plan",
"/settings/system-general",
"/patrol"
"/settings/infrastructure/agent-doctor"
],
"viewports": [
{
"width": 1280,
"height": 800
"width": 1440,
"height": 900
},
{
"width": 390,
@@ -52,20 +27,15 @@
}
],
"states": [
"Authenticated free self-hosted Roles route with presentationPolicy.hideUpgrade=true, no paid-feature or Plans & Billing navigation, and no trial, upgrade, hosted-handoff, or business-estate prompt.",
"Authenticated free self-hosted direct Plans & Billing route with the Community plan visible while its sidebar navigation item remains absent.",
"The refreshed responsive Settings shell kept direct content inside the 390-pixel viewport after a desktop-to-narrow resize and preserved the General route after mobile navigation.",
"Commercial Roles and direct Plans & Billing states rendered from the final production frontend build at 1280x800 and 390x844 and were inspected from captured Chromium screenshots.",
"Authenticated free self-hosted Patrol rendered only the Watch only mode, with no disabled paid-mode controls, Unlock Patrol modes copy, or Plans & Billing action at 1280x800 and 390x844.",
"The production Patrol build remained within the 390-pixel viewport with no horizontal overflow, while the mode, run control, settings control, attention filters, and finding rows remained visible and usable."
"Authenticated mock Agent Doctor rendered 2 critical, 1 needs-attention, and 29 healthy agents without inventing credential failures from non-authoritative mock token inventory.",
"Credential-repair mode rendered a repair-aware banner and required a separate one-use scoped credential for each selected installation when multiple repairs were present.",
"The live signed-in Agent Doctor retained duplicate Pi installations as distinct rows with their separate agent versions and identities.",
"The Agent Doctor remained within the 390-pixel viewport with no horizontal document overflow."
],
"interactions": [
"Built the production frontend and local Pulse backend from the refreshed isolated candidate, authenticated through the managed Playwright backend, and mocked only the commercial posture, entitlements, runtime capabilities, and security status responses.",
"Opened /settings/security-roles and verified default paid navigation and proactive commercial calls to action were absent while the deliberately reached Roles panel remained usable.",
"Opened /settings/pulse-intelligence/billing/plan directly and verified the Plans & Billing panel and Community plan rendered without restoring its sidebar navigation item.",
"Ran the canonical Settings shell matrix, resized direct Plans & Billing from desktop to 390x844, verified the content remained within the viewport, and navigated the narrow shell to General.",
"Captured and inspected full-page screenshots for commercial desktop and narrow states.",
"Built and embedded the final production frontend, opened /patrol against the isolated backend, confirmed Watch only was the sole free-session Patrol mode, and inspected captured desktop and narrow screenshots.",
"Measured the 390-pixel Patrol document at a 390-pixel scroll width and verified the Run Patrol, Patrol settings, attention-state selector, refresh action, and finding list remained reachable without exposing paid-mode discovery."
"Opened the isolated current-code Agent Doctor and verified the final mock fleet summary after the token-inventory authority fix.",
"Exercised the multiple-installation credential-repair state and verified the installation selector, separate-token guidance, and per-installation unlock behavior.",
"Inspected the live Pi duplicate-installation presentation and confirmed both reporting installations remained independently visible.",
"Resized Agent Doctor to 390x844 and measured a 390-pixel document scroll width against a 390-pixel viewport."
]
}
@@ -76,7 +76,9 @@ export const InfrastructureAgentDoctorPage: Component<InfrastructureAgentDoctorP
) => {
const operations = useInfrastructureOperationsContext();
const summary = createMemo(() => summarizeInfrastructureAgentDoctorTargets(props.targets));
const anyTargetNeedsUpdate = createMemo(() => props.targets.some((target) => target.needsUpdate));
const anyTargetNeedsRepair = createMemo(() =>
props.targets.some((target) => target.needsUpdate || target.needsCredentialRepair),
);
const summaryChips = createMemo(() => {
const counts = summary();
const order: { status: InfrastructureAgentDoctorStatus; count: number }[] = [
@@ -92,30 +94,56 @@ export const InfrastructureAgentDoctorPage: Component<InfrastructureAgentDoctorP
const commandTargets = createMemo(() =>
props.targets.filter(
(target) =>
target.needsUpdate &&
(target.needsUpdate || target.needsCredentialRepair) &&
Boolean(target.connection) &&
Boolean(target.commandPlatform) &&
!target.commandBlockedReason,
),
);
const tokenGatedTargetCount = createMemo(
() =>
commandTargets().filter(
(target) =>
target.connection &&
operations.getAgentConnectionUpgradeCommandRequiresToken(
target.connection,
target.commandPlatform ?? undefined,
),
).length,
const tokenGatedTargets = createMemo(() =>
commandTargets().filter(
(target) =>
target.connection &&
operations.getAgentConnectionUpgradeCommandRequiresToken(
target.connection,
target.commandPlatform ?? undefined,
target.needsCredentialRepair,
),
),
);
const commandReadyForTarget = (target: InfrastructureAgentDoctorTarget) =>
Boolean(target.connection && target.commandPlatform && !target.commandBlockedReason) &&
(!operations.getAgentConnectionUpgradeCommandRequiresToken(
const tokenGatedTargetCount = createMemo(() => tokenGatedTargets().length);
const [selectedTokenTargetKey, setSelectedTokenTargetKey] = createSignal('');
const [tokenMintedForTargetKey, setTokenMintedForTargetKey] = createSignal('');
const selectedTokenTarget = createMemo(
() =>
tokenGatedTargets().find((target) => target.key === selectedTokenTargetKey()) ??
tokenGatedTargets()[0],
);
const commandRequiresToken = (target: InfrastructureAgentDoctorTarget) =>
operations.getAgentConnectionUpgradeCommandRequiresToken(
target.connection!,
target.commandPlatform!,
) ||
operations.commandsUnlocked());
target.needsCredentialRepair,
);
const commandReadyForTarget = (target: InfrastructureAgentDoctorTarget) => {
if (!target.connection || !target.commandPlatform || target.commandBlockedReason) return false;
if (!commandRequiresToken(target)) return true;
if (!operations.commandsUnlocked()) return false;
return tokenGatedTargetCount() === 1 || tokenMintedForTargetKey() === target.key;
};
const generateRepairToken = async () => {
const target = selectedTokenTarget();
if (!target) return;
const previousToken = operations.currentToken();
operations.setEnableCommands(Boolean(target.connection?.agentIdentity?.commandsEnabled));
await operations.handleGenerateToken();
const generatedToken = operations.currentToken();
if (generatedToken && generatedToken !== previousToken) {
setTokenMintedForTargetKey(target.key);
setSelectedTokenTargetKey(target.key);
}
};
const copyCommand = async (command: string) => {
const success = await copyToClipboard(command);
@@ -260,11 +288,11 @@ export const InfrastructureAgentDoctorPage: Component<InfrastructureAgentDoctorP
</Button>
</section>
<Show when={anyTargetNeedsUpdate()}>
<Show when={anyTargetNeedsRepair()}>
<div class="rounded-md border border-blue-200 bg-blue-50 px-4 py-3 text-xs leading-5 text-blue-900 dark:border-blue-800 dark:bg-blue-950/30 dark:text-blue-100">
Update commands are host-local: copy one to the affected machine to update its Pulse
Agent from this server. They do not update the Pulse server runtime and Pulse does not
run them remotely.
Update and authentication-repair commands are host-local: copy one to the affected
machine to repair its Pulse Agent from this server. They do not update the Pulse server
runtime and Pulse does not run them remotely.
</div>
</Show>
@@ -272,7 +300,7 @@ export const InfrastructureAgentDoctorPage: Component<InfrastructureAgentDoctorP
when={
tokenGatedTargetCount() > 0 &&
operations.requiresToken() &&
!operations.commandsUnlocked()
(!operations.commandsUnlocked() || tokenGatedTargetCount() > 1)
}
>
<section class="space-y-3 rounded-md border border-blue-200 bg-blue-50 px-4 py-3 dark:border-blue-800 dark:bg-blue-950/30">
@@ -282,18 +310,35 @@ export const InfrastructureAgentDoctorPage: Component<InfrastructureAgentDoctorP
</h3>
<p class="text-xs leading-5 text-blue-800 dark:text-blue-200">
{tokenGatedTargetCount() === 1
? 'One Windows repair needs a scoped install token before Pulse can show its command.'
: `${tokenGatedTargetCount()} Windows repairs need a scoped install token before Pulse can show their commands.`}
? 'One repair needs a fresh scoped agent credential before Pulse can show its command.'
: `${tokenGatedTargetCount()} repairs need separate scoped credentials. Each credential binds to the first agent that uses it; select one installation, generate and run its command, then continue with the next.`}
</p>
</div>
<div class="flex flex-col gap-2 sm:flex-row">
<Show when={tokenGatedTargetCount() > 1}>
<select
value={selectedTokenTarget()?.key}
onChange={(event) => setSelectedTokenTargetKey(event.currentTarget.value)}
aria-label="Agent installation to repair"
class="min-h-10 flex-1 rounded-md border border-blue-200 bg-surface px-3 py-2 text-sm text-base-content shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-blue-700 dark:bg-blue-950 dark:focus:ring-blue-900"
>
<For each={tokenGatedTargets()}>
{(target) => (
<option value={target.key}>
{target.displayName} · {target.connectionId}
{target.currentVersion ? ` · ${target.currentVersion}` : ''}
</option>
)}
</For>
</select>
</Show>
<input
type="text"
value={operations.tokenName()}
onInput={(event) => operations.setTokenName(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !operations.isGeneratingToken()) {
void operations.handleGenerateToken();
void generateRepairToken();
}
}}
placeholder="Token name (optional)"
@@ -301,11 +346,15 @@ export const InfrastructureAgentDoctorPage: Component<InfrastructureAgentDoctorP
/>
<button
type="button"
onClick={() => void operations.handleGenerateToken()}
onClick={() => void generateRepairToken()}
disabled={operations.isGeneratingToken()}
class="inline-flex min-h-10 items-center justify-center rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{operations.isGeneratingToken() ? 'Generating...' : 'Generate token'}
{operations.isGeneratingToken()
? 'Generating...'
: operations.commandsUnlocked()
? 'Generate fresh token'
: 'Generate token'}
</button>
</div>
</section>
@@ -375,11 +424,14 @@ export const InfrastructureAgentDoctorPage: Component<InfrastructureAgentDoctorP
target.connection,
target.installFlags,
target.commandPlatform,
target.needsCredentialRepair,
)
: '';
const otherRepairs = () =>
(target.diagnostic?.repairActions ?? []).filter(
(action) => action.code !== 'copy_upgrade_command',
(action) =>
action.code !== 'copy_upgrade_command' &&
action.code !== 'repair_authentication',
);
const uninstallHandoff = () =>
getInfrastructureAgentDoctorUninstallHandoff(target);
@@ -506,7 +558,7 @@ export const InfrastructureAgentDoctorPage: Component<InfrastructureAgentDoctorP
)}
</For>
<Show when={target.needsUpdate}>
<Show when={target.needsUpdate || target.needsCredentialRepair}>
<Show
when={!target.commandBlockedReason}
fallback={
@@ -519,15 +571,17 @@ export const InfrastructureAgentDoctorPage: Component<InfrastructureAgentDoctorP
when={commandReadyForTarget(target)}
fallback={
<div class="rounded-md border border-border bg-surface px-3 py-3 text-xs text-muted">
Generate a token to unlock this host-local update command.
{commandRequiresToken(target) && tokenGatedTargetCount() > 1
? 'Select this installation above and generate a separate credential to unlock its host-local repair command.'
: 'Generate a token to unlock this host-local repair command.'}
</div>
}
>
<div class="relative">
<CommandCopyButton
onClick={() => void copyCommand(command())}
title="Copy host-local agent update command"
label={`Copy update command for ${target.displayName}`}
title="Copy host-local agent repair command"
label={`Copy ${target.needsCredentialRepair ? 'authentication repair' : 'update'} command for ${target.displayName}`}
/>
<pre class="overflow-x-auto rounded-md bg-base p-3 pr-12 text-xs text-base-content">
<code>{command()}</code>
@@ -121,6 +121,93 @@ describe('Agent Doctor model', () => {
expect(targets[0].commandBlockedReason).toBeUndefined();
});
it('turns a missing credential into a token-gated authentication repair', () => {
const connection = connectionFixture({
agentUpdateAvailable: false,
agentVersion: '6.2.0',
expectedAgentVersion: '6.2.0',
fleet: {
versionDrift: 'current',
credentialStatus: 'invalid',
credentialHealth: {
status: 'invalid',
kind: 'agent-token',
rotation: 'invalid',
},
} as Connection['fleet'],
});
const [target] = collectInfrastructureAgentDoctorTargets({
rows: [rowFixture(connection)],
connections: [connection],
diagnostics: [
diagnosticFixture({
status: 'critical',
version: '6.2.0',
reasons: [
{
code: 'agent_credential_missing',
severity: 'critical',
message: 'The credential no longer exists.',
},
],
repairActions: [
{
code: 'repair_authentication',
label: 'Repair authentication',
description: 'Generate a fresh scoped credential.',
supported: true,
platform: 'linux',
},
],
}),
],
diagnosticsAvailable: true,
targetVersion: '6.2.0',
});
expect(target).toMatchObject({
status: 'critical',
needsUpdate: false,
needsCredentialRepair: true,
commandPlatform: 'linux',
});
expect(target.commandBlockedReason).toBeUndefined();
});
it('fails closed when duplicate host installations make the repair target ambiguous', () => {
const connection = connectionFixture();
const [target] = collectInfrastructureAgentDoctorTargets({
rows: [rowFixture(connection)],
connections: [connection],
diagnostics: [
diagnosticFixture({
status: 'critical',
reasons: [
{
code: 'duplicate_host_agent_installation',
severity: 'critical',
message: 'Multiple host agents report from this machine.',
},
...diagnosticFixture().reasons,
],
repairActions: [
{
code: 'copy_upgrade_command',
label: 'Copy upgrade command',
description: 'Unsafe generic repair.',
supported: false,
platform: 'linux',
},
],
}),
],
diagnosticsAvailable: true,
targetVersion: '6.2.0',
});
expect(target.commandBlockedReason).toContain('Multiple host-installed Pulse Agents');
});
it('does not invent a deployed v0 profile version when no legacy acknowledgement exists', () => {
const connection = connectionFixture({
agentUpdateAvailable: false,
@@ -572,6 +659,7 @@ describe('Agent Doctor model', () => {
reasons: [],
evidence: [],
needsUpdate: false,
needsCredentialRepair: false,
commandPlatform: null,
source: 'diagnostics',
...overrides,
@@ -522,4 +522,65 @@ describe('useInfrastructureOperationsState command-building closures', () => {
dispose();
});
});
describe('Linux authentication repair commands', () => {
it('uses a fresh credential through a temporary token file and preserves update flags', async () => {
const { state, dispose } = mountHook();
await flushAsync();
state.setCustomAgentUrl(HTTPS_URL);
await state.handleGenerateToken();
const cmd = state.getAgentConnectionUpgradeCommand(
baseConnection,
['--enable-proxmox', '--proxmox-type', 'pve'],
'linux',
true,
);
expect(cmd).toContain('tmp_dir=$(mktemp -d)');
expect(cmd).toContain('printf %s \'tok-1\' > "$token_file"');
expect(cmd).toContain('--token-file "$token_file"');
expect(cmd).toContain('--update');
expect(cmd).toContain('--enable-proxmox');
expect(cmd).toContain('--proxmox-type');
expect(cmd).toContain('pve');
expect(cmd).not.toContain("--token 'tok-1'");
dispose();
});
it('keeps an ordinary Linux update tokenless', async () => {
const { state, dispose } = mountHook();
await flushAsync();
state.setCustomAgentUrl(HTTPS_URL);
const cmd = state.getAgentConnectionUpgradeCommand(baseConnection, [], 'linux');
expect(cmd).toContain('| { if [ "$(id -u)" -eq 0 ]; then bash -s -- --update');
expect(cmd).not.toContain('--token-file');
expect(cmd).not.toContain('mktemp -d');
dispose();
});
it('requires a fresh token for explicit or ledger-derived credential repair', async () => {
const { state, dispose } = mountHook();
await flushAsync();
expect(
state.getAgentConnectionUpgradeCommandRequiresToken(baseConnection, 'linux', true),
).toBe(true);
expect(
state.getAgentConnectionUpgradeCommandRequiresToken(
{
...baseConnection,
fleet: { credentialStatus: 'invalid' } as Connection['fleet'],
},
'linux',
),
).toBe(true);
expect(state.getAgentConnectionUpgradeCommandRequiresToken(baseConnection, 'linux')).toBe(
false,
);
dispose();
});
});
});
@@ -32,6 +32,7 @@ export type InfrastructureAgentDoctorTarget = Omit<
reasons: AgentFleetDiagnosticReason[];
evidence: string[];
needsUpdate: boolean;
needsCredentialRepair: boolean;
commandPlatform: AgentCommandPlatform | null;
commandBlockedReason?: string;
updaterLabel?: string;
@@ -311,6 +312,22 @@ const ledgerFallbackReasons = (
break;
}
if (
connection.state !== 'unauthorized' &&
(connection.fleet?.credentialStatus === 'invalid' ||
connection.fleet?.credentialHealth?.status === 'expired')
) {
reasons.push(
fallbackReason(
'ledger_credential_invalid',
'critical',
connection.fleet?.credentialHealth?.status === 'expired'
? 'The agent credential has expired.'
: 'The agent credential is missing or no longer valid on this Pulse server.',
),
);
}
if (connection.agentUpdate?.state === 'error') {
reasons.push(
fallbackReason(
@@ -482,7 +499,13 @@ const doctorTargetFromBinding = (
? Array.from(
new Map(
[
...fallbackReasons.filter((reason) => reason.code === 'command_channel_disconnected'),
...fallbackReasons.filter((reason) =>
[
'command_channel_disconnected',
'ledger_credential_invalid',
'ledger_unauthorized',
].includes(reason.code),
),
...(diagnostic.reasons ?? []),
].map((reason) => [reason.code, reason]),
).values(),
@@ -514,8 +537,22 @@ const doctorTargetFromBinding = (
}
const commandPlatform = resolveKnownAgentCommandPlatform(connection.agentIdentity?.platform);
const needsCredentialRepair = reasons.some((reason) =>
[
'agent_credential_missing',
'agent_credential_expired',
'ledger_credential_invalid',
'ledger_unauthorized',
].includes(reason.code),
);
const hasDuplicateInstallation = reasons.some(
(reason) => reason.code === 'duplicate_host_agent_installation',
);
let commandBlockedReason: string | undefined;
if (needsUpdate && !expectedVersion) {
if ((needsUpdate || needsCredentialRepair) && hasDuplicateInstallation) {
commandBlockedReason =
'Multiple host-installed Pulse Agents are reporting from this machine. Pulse cannot safely choose a local service; inspect and remove or explicitly update the duplicate installation first.';
} else if (needsUpdate && !expectedVersion) {
commandBlockedReason = 'No supported target version is available, so Pulse will not guess.';
} else if (needsUpdate && !parseAgentVersion(expectedVersion)) {
commandBlockedReason = 'The reported target version is not a supported release version.';
@@ -545,6 +582,22 @@ const doctorTargetFromBinding = (
commandBlockedReason = 'The diagnostic service did not offer a supported update repair.';
}
const hasStructuredCredentialRepair = Boolean(
diagnostic?.repairActions?.some(
(action) => action.code === 'repair_authentication' && action.supported,
),
);
if (
needsCredentialRepair &&
diagnosticsAvailable &&
diagnostic &&
!hasStructuredCredentialRepair &&
!commandBlockedReason
) {
commandBlockedReason =
'The diagnostic service did not identify a safe local authentication repair path.';
}
const profileLabel =
diagnostic?.profileName?.trim() || diagnostic?.profileId?.trim() || undefined;
const profileVersionLabel = diagnosticProfileVersionLabel(diagnostic);
@@ -563,6 +616,7 @@ const doctorTargetFromBinding = (
reasons,
evidence: evidenceFor(connection, diagnostic),
needsUpdate,
needsCredentialRepair,
commandPlatform,
commandBlockedReason,
updaterLabel: updater.label,
@@ -626,6 +680,7 @@ const diagnosticOnlyDoctorTarget = (
reasons: diagnostic.reasons ?? [],
evidence: evidenceFor(undefined, diagnostic),
needsUpdate: false,
needsCredentialRepair: false,
commandPlatform: resolveKnownAgentCommandPlatform(diagnostic.platform),
profileLabel: diagnostic.profileName?.trim() || diagnostic.profileId?.trim() || undefined,
profileVersionLabel: diagnosticProfileVersionLabel(diagnostic),
@@ -2,6 +2,7 @@ import { createContext, useContext, type ParentComponent } from 'solid-js';
import type { Connection } from '@/api/connections';
import {
buildPowerShellInstallScriptBootstrap,
buildUnixAgentInstallCommand,
buildWindowsAgentInstallCommand,
powerShellQuote,
resolveAgentCommandPlatform,
@@ -186,6 +187,7 @@ export const useInfrastructureOperationsState = (
connection: Connection,
installFlags: string[] = [],
platformOverride?: AgentPlatform,
replaceCredential = false,
) => {
const token = resolvedCommandToken();
const url = installState.selectedAgentUrl();
@@ -216,6 +218,20 @@ export const useInfrastructureOperationsState = (
});
}
if (replaceCredential) {
const extraArgs = ['--update', ...installFlags];
if (commandsEnabled || installState.enableCommands()) {
extraArgs.push('--enable-commands');
}
return buildUnixAgentInstallCommand({
baseUrl: url,
token,
insecure: installState.insecureMode(),
caCertPath: selectedCustomCaPath(),
extraArgs,
});
}
let command = `curl ${getCurlFlags()}${getShellCustomCaCurlFlag()} ${shellQuoteArg(`${url}/install.sh`)} | bash -s -- --update --url ${shellQuoteArg(url)} --non-interactive`;
if (installFlags.length > 0) {
command += ` ${installFlags.join(' ')}`;
@@ -233,9 +249,14 @@ export const useInfrastructureOperationsState = (
const getAgentConnectionUpgradeCommandRequiresToken = (
connection: Connection,
platformOverride?: AgentPlatform,
replaceCredential = false,
) =>
(platformOverride ?? getConnectionUpgradePlatform(connection)) === 'windows' &&
installState.requiresToken();
installState.requiresToken() &&
((platformOverride ?? getConnectionUpgradePlatform(connection)) === 'windows' ||
replaceCredential ||
connection.state === 'unauthorized' ||
connection.fleet?.credentialStatus === 'invalid' ||
connection.fleet?.credentialHealth?.status === 'expired');
return {
...installState,
+48 -20
View File
@@ -90,6 +90,7 @@ const (
fleetCredentialRotationExpired = "expired"
fleetCredentialRotationExpiring = "expiring"
fleetCredentialRotationHealthy = "healthy"
fleetCredentialRotationInvalid = "invalid"
fleetCredentialRotationNone = "not-applicable"
fleetCredentialStatusExpired = "expired"
fleetCredentialStatusExpiring = "expiring"
@@ -107,21 +108,22 @@ const (
// from the handler makes the aggregator unit-testable without spinning up a
// monitor or persistence layer.
type aggregatorInputs struct {
pveInstances []config.PVEInstance
pbsInstances []config.PBSInstance
pmgInstances []config.PMGInstance
vmwareInstances []config.VMwareVCenterInstance
vmwareSummaries map[string]monitoring.VMwareConnectionSummary
truenasInstances []config.TrueNASInstance
truenasSummaries map[string]monitoring.TrueNASConnectionSummary
availabilityTargets []config.AvailabilityTarget
availabilityStatuses map[string]monitoring.AvailabilityProbeStatus
hosts []models.Host
apiTokens []config.APITokenRecord
agentDesiredConfigs map[string]connectionAgentDesiredConfig
instanceHealth map[string]monitoring.InstanceHealth
expectedAgentVersion string
now time.Time
pveInstances []config.PVEInstance
pbsInstances []config.PBSInstance
pmgInstances []config.PMGInstance
vmwareInstances []config.VMwareVCenterInstance
vmwareSummaries map[string]monitoring.VMwareConnectionSummary
truenasInstances []config.TrueNASInstance
truenasSummaries map[string]monitoring.TrueNASConnectionSummary
availabilityTargets []config.AvailabilityTarget
availabilityStatuses map[string]monitoring.AvailabilityProbeStatus
hosts []models.Host
apiTokens []config.APITokenRecord
agentTokenInventoryKnown bool
agentDesiredConfigs map[string]connectionAgentDesiredConfig
instanceHealth map[string]monitoring.InstanceHealth
expectedAgentVersion string
now time.Time
// pbsReportedNodeNames maps a configured PBS instance name to the hostname
// that node reports about itself. Reported identity outranks configured
@@ -175,6 +177,7 @@ func buildConnections(in aggregatorInputs) []Connection {
for _, target := range in.availabilityTargets {
out = append(out, buildAvailabilityConnection(target, in.availabilityStatuses[target.ID], now))
}
agentTokenRecords := connectionAgentTokenRecordsByID(in.apiTokens)
for _, host := range in.hosts {
// Integration-monitored machines (vSphere ESXi hosts, TrueNAS boxes)
// are represented by their owning platform connection; fabricating an
@@ -184,7 +187,7 @@ func buildConnections(in aggregatorInputs) []Connection {
continue
}
desiredConfig := connectionAgentDesiredConfigForHost(in.agentDesiredConfigs, host.ID)
out = append(out, buildAgentConnection(host, in.expectedAgentVersion, now, desiredConfig))
out = append(out, buildAgentConnection(host, in.expectedAgentVersion, now, desiredConfig, agentTokenRecords[strings.TrimSpace(host.TokenID)], in.agentTokenInventoryKnown))
}
sort.Slice(out, func(i, j int) bool {
@@ -522,7 +525,7 @@ func buildAvailabilityConnection(target config.AvailabilityTarget, status monito
// buildAgentConnection derives a connection row from an agent Host record.
// Agents have no pause toggle and no scope — reports are all-or-nothing —
// so capability flags are off.
func buildAgentConnection(host models.Host, expectedAgentVersion string, now time.Time, desiredConfig *connectionAgentDesiredConfig) Connection {
func buildAgentConnection(host models.Host, expectedAgentVersion string, now time.Time, desiredConfig *connectionAgentDesiredConfig, tokenRecord *config.APITokenRecord, tokenInventoryKnown bool) Connection {
name := host.DisplayName
if strings.TrimSpace(name) == "" {
name = host.Hostname
@@ -583,8 +586,11 @@ func buildAgentConnection(host models.Host, expectedAgentVersion string, now tim
agentID: strings.TrimSpace(host.ID),
agentTokenID: strings.TrimSpace(host.TokenID),
}, now)
if tokenInventoryKnown && agentCredentialRecordInvalid(host, tokenRecord, now) {
conn.Fleet.CredentialStatus = fleetStateInvalid
}
conn.Fleet.ConfigDrift = connectionFleetAgentConfigDrift(conn, desiredConfig, host.AppliedConfig)
conn.Fleet.CredentialHealth = connectionFleetAgentCredentialHealth(conn, host, now)
conn.Fleet.CredentialHealth = connectionFleetAgentCredentialHealth(conn, host, tokenRecord, tokenInventoryKnown, now)
conn.Fleet.CommandPolicy = connectionFleetAgentCommandPolicy(conn, host, desiredConfig)
conn.Fleet.Rollout = connectionFleetRollout(conn)
return conn
@@ -994,12 +1000,34 @@ func connectionFleetCredentialHealthNotApplicable() *ConnectionFleetCredentialHe
}
}
func connectionFleetAgentCredentialHealth(conn Connection, host models.Host, now time.Time) *ConnectionFleetCredentialHealth {
func agentCredentialRecordInvalid(host models.Host, record *config.APITokenRecord, now time.Time) bool {
if strings.TrimSpace(host.TokenID) == "" {
return false
}
if record == nil {
return true
}
return record.ExpiresAt != nil && !record.ExpiresAt.After(now)
}
func connectionFleetAgentCredentialHealth(conn Connection, host models.Host, record *config.APITokenRecord, tokenInventoryKnown bool, now time.Time) *ConnectionFleetCredentialHealth {
kind := fleetStateUnknown
if strings.TrimSpace(host.TokenID) != "" || strings.TrimSpace(host.TokenName) != "" || strings.TrimSpace(host.TokenHint) != "" {
kind = fleetCredentialKindAgentToken
}
return connectionFleetCredentialHealth(conn, kind, host.TokenLastUsedAt, nil, now)
lastUsedAt := host.TokenLastUsedAt
var expiresAt *time.Time
if record != nil {
lastUsedAt = record.LastUsedAt
expiresAt = record.ExpiresAt
}
health := connectionFleetCredentialHealth(conn, kind, lastUsedAt, expiresAt, now)
if tokenInventoryKnown && strings.TrimSpace(host.TokenID) != "" && record == nil {
health.Status = fleetStateInvalid
health.Rotation = fleetCredentialRotationInvalid
health.LastVerifiedAt = nil
}
return health
}
func connectionFleetCommandPolicy(conn Connection) *ConnectionFleetCommandPolicy {
@@ -620,6 +620,102 @@ func TestBuildConnections_AgentFleetGovernance(t *testing.T) {
}
}
func TestBuildConnections_AgentCredentialInventoryDrivesFleetHealth(t *testing.T) {
now := time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC)
lastUsedAt := now.Add(-time.Minute)
expiredAt := now.Add(-time.Hour)
tests := []struct {
name string
tokenID string
tokens []config.APITokenRecord
wantStatus string
wantHealthStatus string
wantHealthRotation string
}{
{
name: "active",
tokenID: "active-token",
tokens: []config.APITokenRecord{{ID: "active-token", LastUsedAt: &lastUsedAt}},
wantStatus: fleetStateVerified,
wantHealthStatus: fleetStateVerified,
wantHealthRotation: fleetCredentialRotationHealthy,
},
{
name: "missing or revoked",
tokenID: "missing-token",
wantStatus: fleetStateInvalid,
wantHealthStatus: fleetStateInvalid,
wantHealthRotation: fleetCredentialRotationInvalid,
},
{
name: "expired",
tokenID: "expired-token",
tokens: []config.APITokenRecord{{
ID: "expired-token",
ExpiresAt: &expiredAt,
}},
wantStatus: fleetStateInvalid,
wantHealthStatus: fleetCredentialStatusExpired,
wantHealthRotation: fleetCredentialRotationExpired,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
connections := buildConnections(aggregatorInputs{
apiTokens: test.tokens,
agentTokenInventoryKnown: true,
hosts: []models.Host{{
ID: "agent-auth",
Hostname: "agent-auth",
Status: "online",
LastSeen: now,
AgentVersion: "6.2.0",
TokenID: test.tokenID,
}},
expectedAgentVersion: "6.2.0",
now: now,
})
if len(connections) != 1 {
t.Fatalf("connections = %d, want 1", len(connections))
}
fleet := connections[0].Fleet
if fleet.CredentialStatus != test.wantStatus {
t.Fatalf("credential status = %q, want %q", fleet.CredentialStatus, test.wantStatus)
}
if fleet.CredentialHealth == nil ||
fleet.CredentialHealth.Status != test.wantHealthStatus ||
fleet.CredentialHealth.Rotation != test.wantHealthRotation {
t.Fatalf("credential health = %+v, want status %q rotation %q", fleet.CredentialHealth, test.wantHealthStatus, test.wantHealthRotation)
}
})
}
}
func TestBuildConnections_UnknownCredentialInventoryDoesNotInventInvalidState(t *testing.T) {
now := time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC)
connections := buildConnections(aggregatorInputs{
hosts: []models.Host{{
ID: "agent-mock",
Hostname: "agent-mock",
Status: "online",
LastSeen: now,
AgentVersion: "6.2.0",
TokenID: "synthetic-token",
}},
expectedAgentVersion: "6.2.0",
now: now,
})
if len(connections) != 1 {
t.Fatalf("connections = %d, want 1", len(connections))
}
if got := connections[0].Fleet.CredentialStatus; got != fleetStateVerified {
t.Fatalf("credential status = %q, want %q when inventory is unavailable", got, fleetStateVerified)
}
}
func TestBuildConnections_AgentWithoutManagedDesiredConfigDoesNotReportRolloutPending(t *testing.T) {
now := time.Now()
in := aggregatorInputs{
+2
View File
@@ -31,6 +31,7 @@ func buildAggregatorInputsWithRuntimeSources(
inputs.pbsInstances = cfg.PBSInstances
inputs.pmgInstances = cfg.PMGInstances
inputs.apiTokens = append([]config.APITokenRecord(nil), cfg.APITokens...)
inputs.agentTokenInventoryKnown = true
inputs.pvePollingInterval = cfg.PVEPollingInterval
inputs.pbsPollingInterval = cfg.PBSPollingInterval
inputs.pmgPollingInterval = cfg.PMGPollingInterval
@@ -72,6 +73,7 @@ func buildAggregatorInputsWithRuntimeSources(
}
if mock.IsMockEnabled() {
inputs = applyMockLedgerInputs(inputs)
inputs.agentTokenInventoryKnown = false
}
inputs.expectedAgentVersion = currentAgentTargetVersion()
_ = ctx
+3
View File
@@ -16216,6 +16216,9 @@ func TestContract_LedgerDropsIntegrationMonitoredMachines(t *testing.T) {
func TestContract_AgentDefaultDesiredConfigDoesNotCreateRolloutAttention(t *testing.T) {
now := time.Date(2026, 5, 14, 10, 30, 0, 0, time.UTC)
connections := buildConnections(aggregatorInputs{
apiTokens: []config.APITokenRecord{{
ID: "token-1",
}},
hosts: []models.Host{
{
ID: "host-1",
+136 -4
View File
@@ -10,7 +10,9 @@ import (
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/fleethealth"
"github.com/rcourtman/pulse-go-rewrite/internal/mock"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/platformsupport"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
@@ -33,10 +35,14 @@ const (
AgentFleetReasonUpdateStateUnverified = "agent_update_installer_state_unverified"
AgentFleetReasonModuleFailed = "agent_module_failed"
AgentFleetReasonModuleDegraded = "agent_module_degraded"
AgentFleetReasonCredentialMissing = "agent_credential_missing"
AgentFleetReasonCredentialExpired = "agent_credential_expired"
AgentFleetReasonDuplicateInstallation = "duplicate_host_agent_installation"
AgentFleetActionAllowReenroll = "allow_reenroll"
AgentFleetActionCopyUpgradeCommand = "copy_upgrade_command"
AgentFleetRepairModeHandoff = "handoff"
AgentFleetActionAllowReenroll = "allow_reenroll"
AgentFleetActionCopyUpgradeCommand = "copy_upgrade_command"
AgentFleetActionRepairAuthentication = "repair_authentication"
AgentFleetRepairModeHandoff = "handoff"
)
type AgentFleetDiagnostics struct {
@@ -143,6 +149,12 @@ type agentFleetSubject struct {
removedPlatform string // last-known reported platform retained on the removed record
}
type agentFleetTokenInventory struct {
known bool
active map[string]struct{}
expired map[string]struct{}
}
// GetAgentFleetDiagnostics preserves the original call contract for callers
// where the server version is also the agent update target.
func (m *Monitor) GetAgentFleetDiagnostics(serverVersion string, now time.Time) AgentFleetDiagnostics {
@@ -169,6 +181,7 @@ func (m *Monitor) GetAgentFleetDiagnosticsForTarget(serverVersion, agentUpdateTa
}
state := m.GetState()
tokenInventory := m.agentFleetTokenInventory(now)
profiles, assignments, deployments := m.agentFleetProfileState()
profileByID := mapProfilesByID(profiles)
assignmentByAgent := mapAssignmentsByAgent(assignments)
@@ -176,7 +189,7 @@ func (m *Monitor) GetAgentFleetDiagnosticsForTarget(serverVersion, agentUpdateTa
subjects := buildAgentFleetSubjects(state)
for i := range subjects {
diagnostic := diagnoseAgentFleetSubject(subjects[i], state, out.AgentUpdateTargetVersion, now, profileByID, assignmentByAgent, deploymentByAgentProfile)
diagnostic := diagnoseAgentFleetSubject(subjects[i], state, out.AgentUpdateTargetVersion, now, tokenInventory, profileByID, assignmentByAgent, deploymentByAgentProfile)
out.Agents = append(out.Agents, diagnostic)
}
@@ -204,6 +217,32 @@ func (m *Monitor) GetAgentFleetDiagnosticsForTarget(serverVersion, agentUpdateTa
return out
}
func (m *Monitor) agentFleetTokenInventory(now time.Time) agentFleetTokenInventory {
if m == nil || m.config == nil || mock.IsMockEnabled() {
return agentFleetTokenInventory{}
}
inventory := agentFleetTokenInventory{
known: true,
active: make(map[string]struct{}),
expired: make(map[string]struct{}),
}
config.Mu.RLock()
defer config.Mu.RUnlock()
for _, record := range m.config.APITokens {
id := strings.TrimSpace(record.ID)
if id == "" {
continue
}
if record.ExpiresAt != nil && !record.ExpiresAt.After(now) {
inventory.expired[id] = struct{}{}
continue
}
inventory.active[id] = struct{}{}
}
return inventory
}
func (m *Monitor) agentFleetProfileState() ([]models.AgentProfile, []models.AgentProfileAssignment, []models.ProfileDeploymentStatus) {
if m == nil || m.persistence == nil {
return nil, nil, nil
@@ -352,6 +391,7 @@ func diagnoseAgentFleetSubject(
state models.StateSnapshot,
serverVersion string,
now time.Time,
tokenInventory agentFleetTokenInventory,
profileByID map[string]models.AgentProfile,
assignmentByAgent map[string]models.AgentProfileAssignment,
deploymentByAgentProfile map[string]models.ProfileDeploymentStatus,
@@ -408,9 +448,11 @@ func diagnoseAgentFleetSubject(
}
result.Reasons = append(result.Reasons, diagnoseAgentConnectivity(subject, now)...)
result.Reasons = append(result.Reasons, diagnoseAgentCredential(subject, tokenInventory)...)
result.Reasons = append(result.Reasons, diagnoseAgentVersion(subject, serverVersion)...)
result.Reasons = append(result.Reasons, diagnoseAgentUpdate(subject, serverVersion)...)
result.Reasons = append(result.Reasons, diagnoseAgentModules(subject)...)
result.Reasons = append(result.Reasons, diagnoseDuplicateHostAgentInstallation(subject, state)...)
result.Reasons = append(result.Reasons, diagnoseAgentIdentitySplit(subject, state)...)
assignment, hasAssignment := findAgentAssignment(subject, assignmentByAgent)
@@ -438,6 +480,22 @@ func diagnoseAgentFleetSubject(
}
}
if diagnosticHasAnyReason(result.Reasons, AgentFleetReasonCredentialMissing, AgentFleetReasonCredentialExpired) {
platform, supported := safeAgentUpdatePlatform(subject)
if platform == platformsupport.RuntimePlatformFreeBSD || diagnosticHasAnyReason(result.Reasons, AgentFleetReasonDuplicateInstallation) {
supported = false
}
result.RepairActions = append(result.RepairActions, AgentFleetDiagnosticRepair{
Code: AgentFleetActionRepairAuthentication,
Label: "Repair authentication",
Description: "Generate a fresh scoped agent credential and run the host-local repair command for this installation.",
Supported: supported,
Mode: AgentFleetRepairModeHandoff,
Platform: platform,
Scope: "settings:write + local_admin_shell",
})
}
for _, reason := range result.Reasons {
if reason.Code == "agent_version_stale" {
platform, supported := safeAgentUpdatePlatform(subject)
@@ -455,6 +513,9 @@ func diagnoseAgentFleetSubject(
Message: "Pulse cannot verify the saved FreeBSD or pfSense installer state required for a safe in-place update.",
})
}
if diagnosticHasAnyReason(result.Reasons, AgentFleetReasonDuplicateInstallation) {
supported = false
}
result.RepairActions = append(result.RepairActions, AgentFleetDiagnosticRepair{
Code: AgentFleetActionCopyUpgradeCommand,
Label: "Copy upgrade command",
@@ -472,6 +533,77 @@ func diagnoseAgentFleetSubject(
return result
}
func diagnosticHasAnyReason(reasons []AgentFleetDiagnosticReason, codes ...string) bool {
for _, reason := range reasons {
for _, code := range codes {
if reason.Code == code {
return true
}
}
}
return false
}
func diagnoseAgentCredential(subject agentFleetSubject, inventory agentFleetTokenInventory) []AgentFleetDiagnosticReason {
tokenID := strings.TrimSpace(subject.tokenID)
if subject.removed || !inventory.known || tokenID == "" {
return nil
}
if _, ok := inventory.active[tokenID]; ok {
return nil
}
if _, ok := inventory.expired[tokenID]; ok {
return []AgentFleetDiagnosticReason{{
Code: AgentFleetReasonCredentialExpired,
Severity: AgentFleetStatusCritical,
Message: "The credential last used by this agent has expired and can no longer authenticate.",
Evidence: []string{"Credential status: expired"},
}}
}
return []AgentFleetDiagnosticReason{{
Code: AgentFleetReasonCredentialMissing,
Severity: AgentFleetStatusCritical,
Message: "The credential last used by this agent no longer exists on this Pulse server.",
Evidence: []string{"Credential status: missing or revoked"},
}}
}
func diagnoseDuplicateHostAgentInstallation(subject agentFleetSubject, state models.StateSnapshot) []AgentFleetDiagnosticReason {
if subject.removed || subject.host == nil {
return nil
}
machineID := strings.TrimSpace(subject.host.MachineID)
hostname := strings.TrimSpace(subject.host.Hostname)
if machineID == "" || hostname == "" {
return nil
}
for _, peer := range state.Hosts {
if strings.TrimSpace(peer.ID) == strings.TrimSpace(subject.host.ID) ||
strings.TrimSpace(peer.MachineID) != machineID ||
!unifiedresources.HostnamesEquivalent(peer.Hostname, hostname) {
continue
}
evidence := []string{
"Peer agent ID: " + strings.TrimSpace(peer.ID),
"Peer hostname: " + strings.TrimSpace(peer.Hostname),
}
if version := strings.TrimSpace(peer.AgentVersion); version != "" {
evidence = append(evidence, "Peer version: "+version)
}
if !peer.LastSeen.IsZero() {
evidence = append(evidence, "Peer last seen: "+peer.LastSeen.UTC().Format(time.RFC3339))
}
return []AgentFleetDiagnosticReason{{
Code: AgentFleetReasonDuplicateInstallation,
Severity: AgentFleetStatusCritical,
Message: "Multiple host-installed Pulse Agents are reporting from the same machine. A generic update command cannot safely choose which local service to change.",
Evidence: evidence,
}}
}
return nil
}
func diagnoseAgentConnectivity(subject agentFleetSubject, now time.Time) []AgentFleetDiagnosticReason {
if subject.kubernetes == nil && subject.host == nil && subject.docker == nil {
return nil
@@ -37,6 +37,129 @@ func TestAgentFleetDiagnosticsDetectsStaleAgentVersion(t *testing.T) {
}
}
func TestAgentFleetDiagnosticsDetectsMissingAndExpiredCredentials(t *testing.T) {
now := time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC)
expiredAt := now.Add(-time.Hour)
tests := []struct {
name string
tokenID string
tokens []config.APITokenRecord
wantReason string
}{
{
name: "missing or revoked",
tokenID: "revoked-token",
wantReason: AgentFleetReasonCredentialMissing,
},
{
name: "expired",
tokenID: "expired-token",
tokens: []config.APITokenRecord{{
ID: "expired-token",
ExpiresAt: &expiredAt,
}},
wantReason: AgentFleetReasonCredentialExpired,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
monitor := newAgentFleetDoctorTestMonitor(t)
monitor.config.APITokens = test.tokens
monitor.state.UpsertHost(models.Host{
ID: "agent-auth",
Hostname: "auth-node",
Platform: "linux",
Status: "online",
LastSeen: now,
AgentVersion: "6.2.0",
TokenID: test.tokenID,
})
agent := requireAgentDiagnostic(t, monitor.GetAgentFleetDiagnostics("6.2.0", now), "agent-agent-auth")
requireReasonCode(t, agent, test.wantReason)
if agent.Status != AgentFleetStatusCritical {
t.Fatalf("status = %q, want %q", agent.Status, AgentFleetStatusCritical)
}
if !hasSupportedRepair(agent, AgentFleetActionRepairAuthentication) {
t.Fatalf("expected safe authentication repair action: %#v", agent.RepairActions)
}
})
}
}
func TestAgentFleetDiagnosticsAcceptsActiveCredential(t *testing.T) {
now := time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC)
monitor := newAgentFleetDoctorTestMonitor(t)
monitor.config.APITokens = []config.APITokenRecord{{ID: "active-token"}}
monitor.state.UpsertHost(models.Host{
ID: "agent-auth",
Hostname: "auth-node",
Platform: "linux",
Status: "online",
LastSeen: now,
AgentVersion: "6.2.0",
TokenID: "active-token",
})
agent := requireAgentDiagnostic(t, monitor.GetAgentFleetDiagnostics("6.2.0", now), "agent-agent-auth")
for _, reason := range agent.Reasons {
if reason.Code == AgentFleetReasonCredentialMissing || reason.Code == AgentFleetReasonCredentialExpired {
t.Fatalf("active credential produced repair reason: %#v", agent.Reasons)
}
}
}
func TestAgentFleetDiagnosticsDoesNotValidateSyntheticMockCredentials(t *testing.T) {
now := time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC)
mustSetMockEnabled(t, true)
t.Cleanup(func() { mustSetMockEnabled(t, false) })
monitor := newAgentFleetDoctorTestMonitor(t)
monitor.config.APITokens = []config.APITokenRecord{{ID: "synthetic-token"}}
if inventory := monitor.agentFleetTokenInventory(now); inventory.known {
t.Fatalf("mock token inventory = %+v, want deliberately unknown", inventory)
}
}
func TestAgentFleetDiagnosticsBlocksGenericRepairForDuplicateHostInstallations(t *testing.T) {
now := time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC)
monitor := newAgentFleetDoctorTestMonitor(t)
for _, host := range []models.Host{
{
ID: "agent-primary",
Hostname: "pi",
MachineID: "machine-pi",
Platform: "linux",
Status: "online",
LastSeen: now,
AgentVersion: "6.2.0-rc.11",
},
{
ID: "agent-legacy",
Hostname: "PI.local",
MachineID: "machine-pi",
Platform: "linux",
Status: "online",
LastSeen: now.Add(-time.Minute),
AgentVersion: "6.2.0-rc.6",
},
} {
monitor.state.UpsertHost(host)
}
for _, rowKey := range []string{"agent-agent-primary", "agent-agent-legacy"} {
agent := requireAgentDiagnostic(t, monitor.GetAgentFleetDiagnostics("6.2.0-rc.11", now), rowKey)
requireReasonCode(t, agent, AgentFleetReasonDuplicateInstallation)
for _, repair := range agent.RepairActions {
if (repair.Code == AgentFleetActionCopyUpgradeCommand || repair.Code == AgentFleetActionRepairAuthentication) && repair.Supported {
t.Fatalf("duplicate installation offered unsafe generic repair: %#v", agent.RepairActions)
}
}
}
}
func TestAgentFleetDiagnosticsSurfacesReportedUpdateModuleAndIdentityEvidence(t *testing.T) {
now := time.Date(2026, 7, 13, 12, 0, 0, 0, time.UTC)
checkedAt := now.Add(-time.Minute)
+11 -1
View File
@@ -233,6 +233,7 @@ EXIT_PREFLIGHT_FAILED=14
EXIT_ALREADY_INSTALLED=15 # Not a failure — used with --preflight-only
EXIT_MISSING_ARGS=16
EXIT_SIGNATURE_FAILED=17
EXIT_AUTH_REJECTED=18
json_event() {
# Usage: json_event <phase> <code> <message> [exitCode]
@@ -641,6 +642,7 @@ verify_agent_started() {
log_info "Agent process is running and registered with Pulse."
elif [[ $reg_rc -eq 2 ]]; then
warn_agent_token_rejected
return 2
else
log_warn "Agent process is running, but server registration was not confirmed yet."
fi
@@ -667,6 +669,7 @@ verify_agent_started() {
log_info "Agent is running, healthy, and registered with Pulse."
elif [[ $reg_rc -eq 2 ]]; then
warn_agent_token_rejected
return 2
else
log_warn "Agent local health is ready, but server registration was not confirmed yet."
fi
@@ -1296,8 +1299,11 @@ complete_installation_flow() {
local upgrade_success_message="$3"
local unhealthy_log_hint="$4"
local verification_rc=0
save_connection_info "$state_dir"
if verify_agent_started; then
verify_agent_started || verification_rc=$?
if [[ $verification_rc -eq 0 ]]; then
report_proxmox_registration_outcome "$state_dir" || true
if [[ "$UPGRADE_MODE" == "true" ]]; then
log_info "$upgrade_success_message"
@@ -1306,6 +1312,10 @@ complete_installation_flow() {
log_info "$install_success_message"
json_event "complete" "installed" "Installation installed"
fi
elif [[ $verification_rc -eq 2 ]]; then
log_error "Pulse Agent authentication failed. The local service is running, but Pulse rejected its credential; installation is not complete. Generate a fresh scoped agent credential in Pulse and run the repair command again."
json_event "complete" "auth_rejected" "Pulse rejected the agent credential" "$EXIT_AUTH_REJECTED"
exit "$EXIT_AUTH_REJECTED"
else
if [[ "$UPGRADE_MODE" == "true" ]]; then
log_warn "Upgrade complete, but the agent may not be running correctly."
+32
View File
@@ -4874,6 +4874,38 @@ func TestInstallSHRegistrationRetryWindowOutlastsFirstReportCycle(t *testing.T)
}
}
func TestInstallSHRejectedCredentialPreventsSuccessfulCompletion(t *testing.T) {
completeFlow := extractInstallShellFunction(t, "complete_installation_flow")
script := `
EXIT_AUTH_REJECTED=18
UPGRADE_MODE="true"
save_connection_info() { :; }
verify_agent_started() { return 2; }
report_proxmox_registration_outcome() { printf 'unexpected-proxmox-report\n'; }
log_info() { printf 'INFO:%s\n' "$*"; }
log_warn() { printf 'WARN:%s\n' "$*"; }
log_error() { printf 'ERROR:%s\n' "$*"; }
json_event() { printf 'JSON:%s:%s:%s:%s\n' "$1" "$2" "$3" "${4:-}"; }
` + completeFlow + `
complete_installation_flow "/tmp/state" "INSTALL-SUCCESS" "UPGRADE-SUCCESS" "logs"
printf 'unreachable\n'
`
out, err := exec.Command("bash", "-c", script).CombinedOutput()
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) || exitErr.ExitCode() != 18 {
t.Fatalf("rejected credential exit = %v, want 18\n%s", err, out)
}
output := string(out)
if !strings.Contains(output, "JSON:complete:auth_rejected") || !strings.Contains(output, "authentication failed") {
t.Fatalf("rejected credential did not produce actionable non-success completion:\n%s", output)
}
for _, forbidden := range []string{"INSTALL-SUCCESS", "UPGRADE-SUCCESS", "unreachable", "unexpected-proxmox-report"} {
if strings.Contains(output, forbidden) {
t.Fatalf("rejected credential emitted forbidden success output %q:\n%s", forbidden, output)
}
}
}
// TestInstallSHSurfacesBlockedProxmoxRegistration verifies the installer reads
// the agent's proxmox-<type>-registration-blocked marker and prints the denial
// in its own output instead of leaving it buried in the agent journal (#1644).