From 63a0adf9ac0828c43db3af6fa15f8f201d0c18ac Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 9 Aug 2026 00:51:38 +0100 Subject: [PATCH] Repair rejected agent credentials safely --- .../v6/internal/subsystems/agent-lifecycle.md | 17 +++ .../v6/internal/subsystems/api-contracts.md | 15 ++ .../subsystems/deployment-installability.md | 9 ++ .../v6/internal/subsystems/monitoring.md | 14 ++ .../internal/subsystems/storage-recovery.md | 10 ++ frontend-modern/browser-verification.json | 68 +++------ .../InfrastructureAgentDoctorPage.tsx | 118 +++++++++++---- .../infrastructureAgentDoctorModel.test.ts | 88 +++++++++++ ...reOperationsState.branchcov0722pm.test.tsx | 61 ++++++++ .../infrastructureAgentUpdateCommandsModel.ts | 59 +++++++- .../useInfrastructureOperationsState.tsx | 25 +++- internal/api/connections_aggregator.go | 68 ++++++--- internal/api/connections_aggregator_test.go | 96 ++++++++++++ internal/api/connections_alerts.go | 2 + internal/api/contract_test.go | 3 + internal/monitoring/agent_fleet_doctor.go | 140 +++++++++++++++++- .../monitoring/agent_fleet_doctor_test.go | 123 +++++++++++++++ scripts/install.sh | 12 +- scripts/installtests/install_sh_test.go | 32 ++++ 19 files changed, 850 insertions(+), 110 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index fefd6e3a7..4dfff6765 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -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`, diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index b63a28546..caacf7d22 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -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`: diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 62ef6e75c..3d3217af1 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -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. diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index a4d2705bf..700980fa8 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -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. diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 957a65efd..8872927be 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -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, diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 0699a7c24..585235dc0 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -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." ] } diff --git a/frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx b/frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx index 2ac673008..3b741367b 100644 --- a/frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx +++ b/frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx @@ -76,7 +76,9 @@ export const InfrastructureAgentDoctorPage: Component { 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 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 - +
- 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.
@@ -272,7 +300,7 @@ export const InfrastructureAgentDoctorPage: Component 0 && operations.requiresToken() && - !operations.commandsUnlocked() + (!operations.commandsUnlocked() || tokenGatedTargetCount() > 1) } >
@@ -282,18 +310,35 @@ export const InfrastructureAgentDoctorPage: Component

{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.`}

+ 1}> + + 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
@@ -375,11 +424,14 @@ export const InfrastructureAgentDoctorPage: Component (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 - + - 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.'} } >
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}`} />
                                       {command()}
diff --git a/frontend-modern/src/components/Settings/__tests__/infrastructureAgentDoctorModel.test.ts b/frontend-modern/src/components/Settings/__tests__/infrastructureAgentDoctorModel.test.ts
index d3db229cb..5f85d1e64 100644
--- a/frontend-modern/src/components/Settings/__tests__/infrastructureAgentDoctorModel.test.ts
+++ b/frontend-modern/src/components/Settings/__tests__/infrastructureAgentDoctorModel.test.ts
@@ -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,
diff --git a/frontend-modern/src/components/Settings/__tests__/useInfrastructureOperationsState.branchcov0722pm.test.tsx b/frontend-modern/src/components/Settings/__tests__/useInfrastructureOperationsState.branchcov0722pm.test.tsx
index 5c1ceb48c..8e8988457 100644
--- a/frontend-modern/src/components/Settings/__tests__/useInfrastructureOperationsState.branchcov0722pm.test.tsx
+++ b/frontend-modern/src/components/Settings/__tests__/useInfrastructureOperationsState.branchcov0722pm.test.tsx
@@ -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();
+    });
+  });
 });
diff --git a/frontend-modern/src/components/Settings/infrastructureAgentUpdateCommandsModel.ts b/frontend-modern/src/components/Settings/infrastructureAgentUpdateCommandsModel.ts
index 1a4ada44f..b46af5783 100644
--- a/frontend-modern/src/components/Settings/infrastructureAgentUpdateCommandsModel.ts
+++ b/frontend-modern/src/components/Settings/infrastructureAgentUpdateCommandsModel.ts
@@ -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),
diff --git a/frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx b/frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx
index 8595614f4..1e99c9784 100644
--- a/frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx
+++ b/frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx
@@ -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,
diff --git a/internal/api/connections_aggregator.go b/internal/api/connections_aggregator.go
index abdd456e2..f119b5b95 100644
--- a/internal/api/connections_aggregator.go
+++ b/internal/api/connections_aggregator.go
@@ -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 {
diff --git a/internal/api/connections_aggregator_test.go b/internal/api/connections_aggregator_test.go
index fd9b71a99..054c04aa4 100644
--- a/internal/api/connections_aggregator_test.go
+++ b/internal/api/connections_aggregator_test.go
@@ -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{
diff --git a/internal/api/connections_alerts.go b/internal/api/connections_alerts.go
index 8916cfaa6..36089aa5d 100644
--- a/internal/api/connections_alerts.go
+++ b/internal/api/connections_alerts.go
@@ -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
diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go
index a4c0fcbeb..db97a5a4d 100644
--- a/internal/api/contract_test.go
+++ b/internal/api/contract_test.go
@@ -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",
diff --git a/internal/monitoring/agent_fleet_doctor.go b/internal/monitoring/agent_fleet_doctor.go
index b3080e45c..75c4c0335 100644
--- a/internal/monitoring/agent_fleet_doctor.go
+++ b/internal/monitoring/agent_fleet_doctor.go
@@ -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
diff --git a/internal/monitoring/agent_fleet_doctor_test.go b/internal/monitoring/agent_fleet_doctor_test.go
index f70a1f57f..5980a4ee1 100644
--- a/internal/monitoring/agent_fleet_doctor_test.go
+++ b/internal/monitoring/agent_fleet_doctor_test.go
@@ -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)
diff --git a/scripts/install.sh b/scripts/install.sh
index 4f0581dab..37a5f9a4e 100755
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -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    [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."
diff --git a/scripts/installtests/install_sh_test.go b/scripts/installtests/install_sh_test.go
index 587bd59de..16adeb688 100644
--- a/scripts/installtests/install_sh_test.go
+++ b/scripts/installtests/install_sh_test.go
@@ -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--registration-blocked marker and prints the denial
 // in its own output instead of leaving it buried in the agent journal (#1644).