Chrome blanks an off-store ExtensionInstallForcelist entry on an unmanaged machine exactly as Edge does - the qualifying states are AD domain join, Entra join, or Chrome Enterprise Core enrolment, and this box is none of them. The guard now covers every browser and refuses up front, with the three ways forward, instead of skipping Edge alone and letting Chrome fail the same way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
237 lines
9.4 KiB
PowerShell
237 lines
9.4 KiB
PowerShell
<#
|
|
.SYNOPSIS
|
|
Install the AutoFirmer extension from our own update server, via policy.
|
|
|
|
.DESCRIPTION
|
|
Chrome will not side-load a CRX on Windows — dropping one on
|
|
chrome://extensions is refused — and it will not update an unpacked folder.
|
|
The supported way to run an extension you host yourself is a policy:
|
|
ExtensionInstallForcelist names the extension id and the update manifest, and
|
|
from then on the browser installs it, keeps it updated on its own schedule,
|
|
and re-installs it if it goes missing.
|
|
|
|
Edge and Brave are Chromium too: same CRX, same policy, same update
|
|
manifest, only a different registry vendor key. By default this configures
|
|
every one of the three that is actually installed.
|
|
|
|
Defaults to the current user's policy hive, which needs no elevation.
|
|
-Scope Machine writes to HKLM instead, for every account on the box; that
|
|
one does need an elevated shell. Where both exist, HKLM wins.
|
|
|
|
After this runs:
|
|
* Remove the unpacked copy from chrome://extensions. It has the same id
|
|
now, and the browser will not load both.
|
|
* Restart the browser, or open chrome://policy and press "Reload policies".
|
|
* A force-installed extension cannot be disabled or removed from the
|
|
extensions page. Re-run with -Remove to take it off.
|
|
|
|
.EXAMPLE
|
|
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\install-extension-policy.ps1
|
|
|
|
.EXAMPLE
|
|
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\install-extension-policy.ps1 -Browser Edge -Scope Machine
|
|
#>
|
|
[CmdletBinding()]
|
|
param(
|
|
[ValidateSet('All', 'Chrome', 'Edge', 'Brave')]
|
|
[string]$Browser = 'All',
|
|
|
|
[ValidateSet('User', 'Machine')]
|
|
[string]$Scope = 'User',
|
|
|
|
# Derived from the signing key on the droplet. It only changes if the key does.
|
|
[string]$Id = 'lkjbblagjjbldlkeaaffhiopdlhniaan',
|
|
|
|
[string]$UpdateUrl = 'https://ext.juicerroom.com/updates.xml',
|
|
|
|
[switch]$Remove,
|
|
|
|
# Write the Edge policy even though Edge will refuse to act on it.
|
|
[switch]$Force
|
|
)
|
|
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
# Vendor key under SOFTWARE\Policies, and the executable each one registers.
|
|
$vendors = @{
|
|
Chrome = 'Google\Chrome'
|
|
Edge = 'Microsoft\Edge'
|
|
Brave = 'BraveSoftware\Brave'
|
|
}
|
|
$exes = @{
|
|
Chrome = 'chrome.exe'
|
|
Edge = 'msedge.exe'
|
|
Brave = 'brave.exe'
|
|
}
|
|
# Where each one actually lands. Edge and Chrome are 32-bit installers on a
|
|
# 64-bit box, which is also why the registry probe below has to look inside
|
|
# WOW6432Node: a 64-bit PowerShell reading HKLM:\SOFTWARE sees the 64-bit view
|
|
# and misses everything a 32-bit installer wrote.
|
|
$installPaths = @{
|
|
Chrome = @("$env:ProgramFiles\Google\Chrome\Application\chrome.exe",
|
|
"${env:ProgramFiles(x86)}\Google\Chrome\Application\chrome.exe",
|
|
"$env:LOCALAPPDATA\Google\Chrome\Application\chrome.exe")
|
|
Edge = @("${env:ProgramFiles(x86)}\Microsoft\Edge\Application\msedge.exe",
|
|
"$env:ProgramFiles\Microsoft\Edge\Application\msedge.exe")
|
|
Brave = @("$env:ProgramFiles\BraveSoftware\Brave-Browser\Application\brave.exe",
|
|
"${env:ProgramFiles(x86)}\BraveSoftware\Brave-Browser\Application\brave.exe",
|
|
"$env:LOCALAPPDATA\BraveSoftware\Brave-Browser\Application\brave.exe")
|
|
}
|
|
|
|
function Test-BrowserInstalled([string]$Name) {
|
|
foreach ($exe in $installPaths[$Name]) {
|
|
if ($exe -and (Test-Path $exe)) { return $true }
|
|
}
|
|
# App Paths as a backstop, for an install somewhere unusual.
|
|
$keys = @(
|
|
"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\$($exes[$Name])",
|
|
"HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\App Paths\$($exes[$Name])",
|
|
"HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\$($exes[$Name])"
|
|
)
|
|
foreach ($key in $keys) {
|
|
if (Test-Path $key) { return $true }
|
|
}
|
|
return $false
|
|
}
|
|
|
|
<#
|
|
The restriction that decides whether any of this works.
|
|
|
|
On Windows, Chromium will only force-install an extension from outside its
|
|
own web store when the instance is joined to an Active Directory domain,
|
|
joined to Entra ID, or enrolled in Chrome Enterprise Core (what used to be
|
|
called Chrome Browser Cloud Management). Edge says the same thing about the
|
|
Microsoft Store. On anything else the policy is read and then blanked:
|
|
chrome://policy shows "[BLOCKED]<id>;<url>" and an "Invalid extension ID"
|
|
error, because the [BLOCKED] prefix is what gets parsed as the id.
|
|
|
|
This is not something the update server can work around — it is about the
|
|
machine, not the extension, the hive, or the browser. So rather than leave a
|
|
permanent policy error and a "Managed by your organization" badge behind for
|
|
nothing, this refuses up front and says what the ways round it are.
|
|
#>
|
|
function Test-DeviceManaged {
|
|
try {
|
|
$status = & dsregcmd /status 2>$null | Out-String
|
|
return ($status -match 'AzureAdJoined\s*:\s*YES') -or
|
|
($status -match 'DomainJoined\s*:\s*YES') -or
|
|
($status -match 'EnterpriseJoined\s*:\s*YES')
|
|
} catch {
|
|
return $false # no dsregcmd is itself a fair sign this is not managed
|
|
}
|
|
}
|
|
|
|
$hive = if ($Scope -eq 'Machine') { 'HKLM:' } else { 'HKCU:' }
|
|
|
|
if ($Scope -eq 'Machine') {
|
|
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
|
if (-not ([Security.Principal.WindowsPrincipal]$identity).IsInRole(
|
|
[Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
|
throw 'Run this from an elevated PowerShell for -Scope Machine, or drop the flag to install it for this user only.'
|
|
}
|
|
}
|
|
|
|
$targets = if ($Browser -eq 'All') {
|
|
@('Chrome', 'Edge', 'Brave') | Where-Object { Test-BrowserInstalled $_ }
|
|
} else {
|
|
@($Browser)
|
|
}
|
|
|
|
if (-not $targets) {
|
|
throw 'Found no Chrome, Edge or Brave installation. Name one with -Browser to configure it regardless of what this can see.'
|
|
}
|
|
|
|
if (-not $Remove -and -not $Force -and -not (Test-DeviceManaged)) {
|
|
Write-Warning @'
|
|
This machine is not enterprise managed - not domain-joined, not Entra-joined,
|
|
not enrolled in Chrome Enterprise Core - and no Chromium browser will
|
|
force-install an extension from outside its own web store on such a machine.
|
|
The policy would be read and blanked. Nothing was written.
|
|
|
|
Three ways forward:
|
|
|
|
1. Load extension\ unpacked. It has no update channel, but it reloads itself
|
|
from disk whenever the dashboard reports a newer version, and the updater
|
|
has already pulled those files. Works today, on any browser.
|
|
|
|
2. Enrol Chrome in Chrome Enterprise Core (free). One registry value -
|
|
CloudManagementEnrollmentToken - makes the browser managed, after which
|
|
this script works exactly as intended and the update server needs no
|
|
changes.
|
|
|
|
3. Publish the extension to a web store. Store-hosted extensions update
|
|
themselves with no policy at all.
|
|
|
|
-Force writes the policy anyway, if you know this machine is managed and
|
|
dsregcmd simply cannot see it.
|
|
'@
|
|
exit 1
|
|
}
|
|
|
|
Write-Host "Configuring: $($targets -join ', ')"
|
|
|
|
|
|
$entry = "$Id;$UpdateUrl"
|
|
|
|
foreach ($target in $targets) {
|
|
$key = "$hive\SOFTWARE\Policies\$($vendors[$target])\ExtensionInstallForcelist"
|
|
|
|
if ($Remove) {
|
|
if (-not (Test-Path $key)) {
|
|
Write-Host "$target : nothing to remove — no $Scope forcelist."
|
|
continue
|
|
}
|
|
$props = Get-ItemProperty $key
|
|
$found = $false
|
|
foreach ($name in ($props.PSObject.Properties.Name | Where-Object { $_ -notlike 'PS*' })) {
|
|
if ($props.$name -like "$Id;*") {
|
|
Remove-ItemProperty -Path $key -Name $name
|
|
Write-Host "$target : removed entry $name ($($props.$name))"
|
|
$found = $true
|
|
}
|
|
}
|
|
if (-not $found) { Write-Host "$target : nothing to remove — $Id was not in the $Scope forcelist." }
|
|
continue
|
|
}
|
|
|
|
if (-not (Test-Path $key)) { New-Item -Path $key -Force | Out-Null }
|
|
|
|
# The forcelist is a list expressed as numbered values, so adding to it means
|
|
# finding a free number rather than overwriting whatever is already there —
|
|
# another extension may well be in the list.
|
|
$props = Get-ItemProperty $key
|
|
$existing = $props.PSObject.Properties |
|
|
Where-Object { $_.Name -notlike 'PS*' -and $_.Value -like "$Id;*" }
|
|
|
|
if ($existing) {
|
|
Set-ItemProperty -Path $key -Name $existing.Name -Value $entry
|
|
Write-Host "$target : updated $Scope forcelist entry $($existing.Name)"
|
|
} else {
|
|
$used = $props.PSObject.Properties.Name |
|
|
Where-Object { $_ -match '^\d+$' } | ForEach-Object { [int]$_ }
|
|
$slot = if ($used) { (($used | Measure-Object -Maximum).Maximum + 1) } else { 1 }
|
|
New-ItemProperty -Path $key -Name "$slot" -Value $entry -PropertyType String -Force | Out-Null
|
|
Write-Host "$target : added $Scope forcelist entry $slot"
|
|
}
|
|
Write-Host " $key"
|
|
}
|
|
|
|
if ($Remove) {
|
|
Write-Host "`nRestart the browser to let the change take effect."
|
|
exit 0
|
|
}
|
|
|
|
Write-Host @"
|
|
|
|
$entry
|
|
|
|
Next:
|
|
1. Remove the unpacked copy from chrome://extensions (same id — both cannot load).
|
|
2. Restart the browser, or open its chrome://policy page and press "Reload policies".
|
|
3. The card should read "Installed by enterprise policy".
|
|
|
|
From here the browser updates it on its own. The extension also asks for a check
|
|
as soon as the dashboard reports a newer version, so a release normally lands in
|
|
seconds rather than waiting out the browser's own cycle.
|
|
"@
|