Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **WMI dependency service enforcement** — `Confirm-CWAADependencyService` ensures `winmgmt` (WMI) is set to Automatic startup and Running before install, repair, and service start. Configurable via `$Script:CWAADependencyServiceNames`; respects `-WhatIf`. Event IDs 2030–2039.

### Changed

### Fixed

## [1.0.0] - 2026-02-03
## [2.0.0] - 2026-02-03

### Added

Expand Down
13 changes: 10 additions & 3 deletions Tests/ConnectWiseAutomateAgent.Mocked.CrossCutting.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ Describe 'Pipeline Support' {
}
Mock Write-CWAAEventLog {}
Mock Get-CimInstance { return @() }
Mock Confirm-CWAADependencyService {}

# Pipe an object with Server and LocationID — bind via ValueFromPipelineByPropertyName
# InstallerToken is provided explicitly (it wouldn't come from Get-CWAAInfo output)
Expand Down Expand Up @@ -179,8 +180,8 @@ Describe 'Pipeline Support' {
It 'accepts Server and TrayPort from piped PSCustomObject' {
$result = InModuleScope 'ConnectWiseAutomateAgent' {
Mock Get-CWAAInfo { [PSCustomObject]@{ TrayPort = '42000' } }
Mock Invoke-Expression { return $null }
function netstat { return @() }
# No process is using the TrayPort: netstat returns nothing matching it.
Mock Get-CWAANetstat { @() }

[PSCustomObject]@{ Server = 'automate.example.com'; TrayPort = 42000 } | Test-CWAAPort -Quiet
}
Expand Down Expand Up @@ -223,7 +224,13 @@ Describe 'Pipeline Support' {

It 'Register-CWAAHealthCheckTask accepts Server as string[] and builds valid command' {
$result = InModuleScope 'ConnectWiseAutomateAgent' {
Mock schtasks { return $null }
# The mock must set $LASTEXITCODE for the /CREATE branch; the code checks it
# after the native call, and a bare 'return $null' leaks a prior exit code.
Mock schtasks {
if ($args -contains '/QUERY') { throw 'Task not found' }
elseif ($args -contains '/DELETE') { return $null }
elseif ($args -contains '/CREATE') { $global:LASTEXITCODE = 0; return 'SUCCESS' }
}
Mock New-CWAABackup {}

[PSCustomObject]@{
Expand Down
42 changes: 35 additions & 7 deletions Tests/ConnectWiseAutomateAgent.Mocked.Installation.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ Describe 'Repair-CWAA' {
It 'returns ActionTaken=None with success' {
$result = InModuleScope 'ConnectWiseAutomateAgent' {
Mock Get-CimInstance { @() }
Mock Confirm-CWAADependencyService {}
Mock Stop-Process {}
Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } }
Mock Get-CWAAInfo {
Expand All @@ -230,6 +231,7 @@ Describe 'Repair-CWAA' {
$script:callCount = 0
$result = InModuleScope 'ConnectWiseAutomateAgent' {
Mock Get-CimInstance { @() }
Mock Confirm-CWAADependencyService {}
Mock Stop-Process {}
Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } }
# First call returns old LastSuccessStatus, subsequent calls return recent
Expand Down Expand Up @@ -268,6 +270,7 @@ Describe 'Repair-CWAA' {
It 'triggers reinstall after failed restart' {
$result = InModuleScope 'ConnectWiseAutomateAgent' {
Mock Get-CimInstance { @() }
Mock Confirm-CWAADependencyService {}
Mock Stop-Process {}
Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } }
# Return old date consistently. The wait loop calls Get-CWAAInfo
Expand Down Expand Up @@ -308,6 +311,7 @@ Describe 'Repair-CWAA' {
It 'attempts a fresh install with provided parameters' {
$result = InModuleScope 'ConnectWiseAutomateAgent' {
Mock Get-CimInstance { @() }
Mock Confirm-CWAADependencyService {}
Mock Stop-Process {}
Mock Get-Service { return $null }
Mock Redo-CWAA {}
Expand All @@ -323,6 +327,7 @@ Describe 'Repair-CWAA' {
It 'reports error when no install settings are available' {
$result = InModuleScope 'ConnectWiseAutomateAgent' {
Mock Get-CimInstance { @() }
Mock Confirm-CWAADependencyService {}
Mock Stop-Process {}
Mock Get-Service { return $null }
Mock Get-CWAAInfo { throw 'Not installed' }
Expand All @@ -340,6 +345,7 @@ Describe 'Repair-CWAA' {
It 'returns error about unreachable server' {
$result = InModuleScope 'ConnectWiseAutomateAgent' {
Mock Get-CimInstance { @() }
Mock Confirm-CWAADependencyService {}
Mock Stop-Process {}
Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } }
Mock Get-CWAAInfo {
Expand All @@ -364,6 +370,7 @@ Describe 'Repair-CWAA' {
It 'reinstalls with the correct server' {
$result = InModuleScope 'ConnectWiseAutomateAgent' {
Mock Get-CimInstance { @() }
Mock Confirm-CWAADependencyService {}
Mock Stop-Process {}
Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } }
Mock Get-CWAAInfo {
Expand All @@ -383,6 +390,30 @@ Describe 'Repair-CWAA' {
$result.Message | Should -Match 'correct server'
}
}

Context 'WMI dependency service' {
It 'ensures the dependency service before remediation' {
InModuleScope 'ConnectWiseAutomateAgent' {
Mock Get-CimInstance { @() }
Mock Confirm-CWAADependencyService {}
Mock Stop-Process {}
Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } }
Mock Get-CWAAInfo {
[PSCustomObject]@{
Server = @('automate.example.com')
LastSuccessStatus = (Get-Date).AddMinutes(-30).ToString()
HeartbeatLastSent = (Get-Date).AddMinutes(-15).ToString()
HeartbeatLastReceived = (Get-Date).AddMinutes(-15).ToString()
}
}
Mock Write-CWAAEventLog {}

Repair-CWAA -InstallerToken 'abc123' -Confirm:$false

Should -Invoke Confirm-CWAADependencyService -Scope It -Times 1
}
}
}
}

# -----------------------------------------------------------------------------
Expand Down Expand Up @@ -795,11 +826,8 @@ Describe 'Test-CWAAPort' {
It 'returns $true' {
$result = InModuleScope 'ConnectWiseAutomateAgent' {
Mock Get-CWAAInfo { [PSCustomObject]@{ TrayPort = '42000' } }
# netstat returns no matching output for the port
$env_windir = $env:windir
Mock Invoke-Expression { return $null }
# Mock netstat by ensuring no process is found on the port
function netstat { return @() }
# No process is using the TrayPort: netstat returns nothing matching it.
Mock Get-CWAANetstat { @() }
Test-CWAAPort -TrayPort 42000 -Quiet
}
$result | Should -BeTrue
Expand All @@ -813,8 +841,8 @@ Describe 'Test-CWAAPort' {
Mock Get-CWAAInfoBackup { return $null }
Mock Get-Process { [PSCustomObject]@{ ProcessName = 'LTSvc'; Id = 1234 } }
Mock Test-Connection { return $true }
# Mock netstat to return a line matching the port with a PID
$Script:MockNetstatOutput = " TCP 0.0.0.0:42000 0.0.0.0:0 LISTENING 1234"
# netstat reports a process (PID 1234) listening on the TrayPort.
Mock Get-CWAANetstat { ' TCP 0.0.0.0:42000 0.0.0.0:0 LISTENING 1234' }

# We need to test the output message
Test-CWAAPort -TrayPort 42000 -Server 'automate.example.com' 2>&1
Expand Down
103 changes: 103 additions & 0 deletions Tests/ConnectWiseAutomateAgent.Mocked.PrivateHelpers.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -614,3 +614,106 @@ Describe 'Invoke-CWAAMsiInstaller' {
}
}
}

# -----------------------------------------------------------------------------
# Confirm-CWAADependencyService Tests
# -----------------------------------------------------------------------------

Describe 'Confirm-CWAADependencyService' {

It 'sets startup type to Automatic for the dependency service' {
InModuleScope 'ConnectWiseAutomateAgent' {
Mock Get-Service { [PSCustomObject]@{ Name = 'winmgmt'; Status = 'Running' } }
Mock Set-Service {}
Mock Start-Service {}
Mock Wait-CWAACondition { $true }

Confirm-CWAADependencyService -Confirm:$false

Should -Invoke Set-Service -Scope It -ParameterFilter { $StartupType -eq 'Automatic' }
}
}

It 'starts the service when it is not running' {
InModuleScope 'ConnectWiseAutomateAgent' {
Mock Get-Service { [PSCustomObject]@{ Name = 'winmgmt'; Status = 'Stopped' } }
Mock Set-Service {}
Mock Start-Service {}
Mock Wait-CWAACondition { $true }
Mock Write-CWAAEventLog {}

Confirm-CWAADependencyService -Confirm:$false

Should -Invoke Start-Service -Scope It -Times 1
Should -Invoke Write-CWAAEventLog -Scope It -ParameterFilter { $EventId -eq 2030 }
}
}

It 'does not start the service when already running' {
InModuleScope 'ConnectWiseAutomateAgent' {
Mock Get-Service { [PSCustomObject]@{ Name = 'winmgmt'; Status = 'Running' } }
Mock Set-Service {}
Mock Start-Service {}
Mock Wait-CWAACondition { $true }

Confirm-CWAADependencyService -Confirm:$false

Should -Invoke Start-Service -Scope It -Times 0
}
}

It 'falls back to sc.exe when Start-Service throws' {
InModuleScope 'ConnectWiseAutomateAgent' {
Mock Get-Service { [PSCustomObject]@{ Name = 'winmgmt'; Status = 'Stopped' } }
Mock Set-Service {}
Mock Start-Service { throw 'cannot start' }
Mock Wait-CWAACondition { $true }
Mock Write-CWAAEventLog {}

{ Confirm-CWAADependencyService -Confirm:$false } | Should -Not -Throw
# No assertion on sc.exe (native exe is not easily mockable); the test confirms
# the throw is caught and the function completes without error.
}
}

It 'logs a warning event when the service never reaches Running' {
InModuleScope 'ConnectWiseAutomateAgent' {
Mock Get-Service { [PSCustomObject]@{ Name = 'winmgmt'; Status = 'Stopped' } }
Mock Set-Service {}
Mock Start-Service {}
Mock Wait-CWAACondition { $false }
Mock Write-CWAAEventLog {}

Confirm-CWAADependencyService -Confirm:$false -WarningAction SilentlyContinue

Should -Invoke Write-CWAAEventLog -Scope It -ParameterFilter { $EventId -eq 2031 }
}
}

It 'skips a service that does not exist without error' {
InModuleScope 'ConnectWiseAutomateAgent' {
Mock Get-Service { $null }
Mock Set-Service {}
Mock Start-Service {}

{ Confirm-CWAADependencyService -Confirm:$false } | Should -Not -Throw

Should -Invoke Set-Service -Scope It -Times 0
Should -Invoke Start-Service -Scope It -Times 0
}
}

It 'makes no changes under -WhatIf' {
InModuleScope 'ConnectWiseAutomateAgent' {
Mock Get-Service { [PSCustomObject]@{ Name = 'winmgmt'; Status = 'Stopped' } }
Mock Set-Service {}
Mock Start-Service {}
Mock Wait-CWAACondition { $true }

Confirm-CWAADependencyService -WhatIf

Should -Invoke Set-Service -Scope It -Times 0
Should -Invoke Start-Service -Scope It -Times 0
}
}
}
14 changes: 14 additions & 0 deletions Tests/ConnectWiseAutomateAgent.Mocked.ServiceOps.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,20 @@ Describe 'Start-CWAA' {
Should -Invoke Invoke-CWAACommand -Times 1 -Scope It -ParameterFilter { $Command -eq 'Send Status' }
}
}

It 'ensures the WMI dependency service before starting the agent' {
InModuleScope 'ConnectWiseAutomateAgent' {
Mock Get-CWAAInfo { [PSCustomObject]@{ TrayPort = '42000' } }
Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } }
Mock Set-Service {}
Mock Invoke-CWAACommand {}
Mock Confirm-CWAADependencyService {}

Start-CWAA -Confirm:$false

Should -Invoke Confirm-CWAADependencyService -Times 1 -Scope It
}
}
}

# =============================================================================
Expand Down
102 changes: 102 additions & 0 deletions source/Private/Confirm-CWAADependencyService.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
function Confirm-CWAADependencyService {
<#
.SYNOPSIS
Ensures the OS services the Automate agent depends on are Automatic and Running.
.DESCRIPTION
Iterates the services named in $Script:CWAADependencyServiceNames (winmgmt/WMI by
default) and makes sure each is set to Automatic startup and is Running. The agent
relies on WMI for inventory, scripting, and check-in, and this module's own
Get-CimInstance calls fail silently when winmgmt is disabled, so this runs before
install, repair, and service start.

For each dependency service:
- If the service is not present, it is skipped (logged via Write-Debug).
- Startup type is set to Automatic unconditionally. This is done without reading
the current StartMode (which would require Win32_Service via WMI, the very
service that may be down) and is idempotent.
- If the service is not Running, a start is attempted via Start-Service (which
resolves dependencies such as RpcSs), falling back to sc.exe start. The function
then polls up to $Script:CWAAServiceWaitTimeoutSec for the Running state.

Remediation outcomes are written to the Windows Event Log (EventIds 2030-2032).
Honors -WhatIf via ShouldProcess; under -WhatIf no changes are made.
.PARAMETER ServiceName
One or more service names to ensure. Defaults to $Script:CWAADependencyServiceNames.
Exposed primarily for testing.
.NOTES
Version: 1.0.0
Author: Chris Taylor
Private function - not exported.
.LINK
https://github.com/christaylorcodes/ConnectWiseAutomateAgent
#>
[CmdletBinding(SupportsShouldProcess = $True)]
Param(
[Parameter()]
[string[]]$ServiceName = $Script:CWAADependencyServiceNames
)

Begin {
Write-Debug "Starting $($MyInvocation.InvocationName)"
}

Process {
foreach ($name in $ServiceName) {
$service = Get-Service $name -ErrorAction SilentlyContinue
if (-not $service) {
Write-Debug "Dependency service '$name' not found. Skipping."
continue
}

Try {
# Force Automatic startup. Set unconditionally; reading the current start mode
# reliably needs WMI, which may be the disabled service we are repairing.
if ($PSCmdlet.ShouldProcess($name, 'Set service startup type to Automatic')) {
Set-Service $name -StartupType Automatic -EA 0 -Confirm:$False -WhatIf:$False
}

# Start the service if it is not already running.
$service = Get-Service $name -ErrorAction SilentlyContinue
if ($service -and $service.Status -ne 'Running') {
if ($PSCmdlet.ShouldProcess($name, 'Start service')) {
Write-Verbose "Dependency service '$name' is $($service.Status). Starting."
Try {
Start-Service $name -ErrorAction Stop -WhatIf:$False -Confirm:$False
}
Catch {
# Fall back to sc.exe, consistent with the rest of the module.
Write-Debug "Start-Service failed for '$name' ($($_.Exception.Message)). Falling back to sc.exe."
$Null = & "$env:windir\system32\sc.exe" start "$name" 2>''
}

# GetNewClosure bakes the loop-local $name into the scriptblock so it
# resolves correctly when Wait-CWAACondition invokes it from its own scope.
$running = Wait-CWAACondition -Condition {
(Get-Service $name -EA 0 | Select-Object -Expand Status -EA 0) -eq 'Running'
}.GetNewClosure() -TimeoutSeconds $Script:CWAAServiceWaitTimeoutSec -IntervalSeconds 2 -Activity "Dependency service '$name' starting"

if ($running) {
Write-Verbose "Dependency service '$name' is Running."
Write-CWAAEventLog -EventId 2030 -EntryType Information -Message "Dependency service '$name' set to Automatic and started."
}
else {
Write-Warning "Dependency service '$name' did not reach the Running state."
Write-CWAAEventLog -EventId 2031 -EntryType Warning -Message "Dependency service '$name' was set to Automatic but did not reach the Running state."
}
}
}
else {
Write-Debug "Dependency service '$name' is already Running."
}
}
Catch {
Write-Warning "Failed to ensure dependency service '$name'. $($_.Exception.Message)"
Write-CWAAEventLog -EventId 2032 -EntryType Error -Message "Failed to ensure dependency service '$name'. Error: $($_.Exception.Message)"
}
}
}

End {
Write-Debug "Exiting $($MyInvocation.InvocationName)"
}
}
Loading
Loading