The managed check asked Windows, but Enterprise Core is browser management by Google: Windows reports a plain standalone machine however enrolled Chrome is. The guard therefore blocked the exact path it recommends. It now asks Chrome first - the dmtoken it writes on a successful enrolment, and the enrolment token that requests one, in both registry views - and only then falls back to domain and Entra join state. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
260 lines
11 KiB
PowerShell
260 lines
11 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 {
|
|
# Chrome Enterprise Core has to be checked first and separately. It is
|
|
# *browser* management, by Google — Windows has no idea it happened, so
|
|
# dsregcmd reports an ordinary standalone machine no matter how enrolled
|
|
# Chrome is. Ask Chrome instead: `dmtoken` is what it writes once an
|
|
# enrolment succeeds, and the enrolment token is the request for one. Both
|
|
# views of the registry, because a 32-bit Chrome writes under WOW6432Node.
|
|
$probes = @(
|
|
@{ Key = 'HKLM:\SOFTWARE\Google\Chrome\Enrollment'; Name = 'dmtoken' },
|
|
@{ Key = 'HKLM:\SOFTWARE\WOW6432Node\Google\Chrome\Enrollment'; Name = 'dmtoken' },
|
|
@{ Key = 'HKLM:\SOFTWARE\Policies\Google\Chrome'; Name = 'CloudManagementEnrollmentToken' }
|
|
)
|
|
foreach ($probe in $probes) {
|
|
$item = Get-ItemProperty -Path $probe.Key -ErrorAction SilentlyContinue
|
|
if ($item -and ($item.PSObject.Properties.Name -contains $probe.Name) -and $item.($probe.Name)) {
|
|
return $true
|
|
}
|
|
}
|
|
|
|
# Then the Windows side, which covers domain and Entra joins.
|
|
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 does not look enterprise managed - no Chrome Enterprise Core
|
|
enrolment token, no domain join, no Entra join - 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.
|
|
|
|
If chrome://management already names your organisation, this check is simply
|
|
wrong: run enroll-chrome-management.ps1 so the token is on this machine, or
|
|
re-run with -Force.
|
|
|
|
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.
|
|
"@
|