DOCS

Per-user apps install as SYSTEM and vanish

The install reports success. The user does not have the application. Nothing in your reporting says anything is wrong.

This is the most expensive silent failure in Windows package management, because every signal you have says it worked.

Plenty of the applications people actually use ship a per-user installer, and several default to it. Brave installs to the user’s local app data and offers no machine-wide option at all. VS Code’s user setup is the one Microsoft recommends, because it needs no administrator rights. Chrome and Zoom both ship a machine-wide MSI alongside a consumer installer that lands per user. That per-user installer writes into the profile of whoever runs it. Package management runs as SYSTEM, because that is the account a service has. So the installer runs, succeeds honestly, and writes itself into SYSTEM’s profile.

The exit code is zero. The install genuinely happened. The person who asked for the software has nothing.

Why you cannot simply uninstall it

The copy that landed under SYSTEM is not a broken install — it is a working application in an account nobody logs into. Its uninstall entry lives in the SYSTEM account’s own registry hive rather than in machine-wide Add/Remove Programs.

Winget builds its installed-package index from exactly two places: the calling identity’s HKCU, and HKLM. It never enumerates HKEY_USERS or loads another profile’s hive. So an uninstall entry stranded in a hive that is not the caller’s is simply not in the index, whatever privileges the caller holds — and this cuts both ways. A user-context winget cannot see what SYSTEM installed, and a SYSTEM-context winget cannot see what a user installed.

There is a second, separate obstacle worth knowing about, because it is easy to conflate with the first: winget also refuses outright to uninstall a user-scope package when running elevated. That is a deliberate product guardrail rather than a visibility problem, and it closes the other obvious workaround.

Operators hit the practical version of this regularly — an application installed under the SYSTEM account context, then winget uninstall reporting that the application is not found. Meanwhile the software is right there, running and self-updating.

So the debt accumulates. Every reconcile cycle re-attempts the install, gets an idempotent “already installed” answer, and reports success again.

Detecting it

The test is a three-part one, and all three parts have to hold before we will call a coordinate hollow:

  • the declared product code is present under HKU\S-1-5-18, checked in both the native and Wow6432Node paths, because the SYSTEM hive is not WOW-redirected
  • there is no matching product code in machine ARP, under either registry view
  • no machine ARP entry carries the same display name as the matched SYSTEM row

That last condition guards against a real machine-wide install that happens to be keyed differently. Getting it wrong in that direction would mean proposing to uninstall software a user legitimately has.

The probe is deliberately three-state rather than boolean. A registry fault answers unknown, never not hollow — a transient read error must never buy a false clean bill of health.

Classification runs on fresh successes and on the idempotent answers — already-installed, update-not-applicable, version-not-newer — because in steady state that is how the condition re-enters. A check that only ran on first install would see this once and never again.

What happens instead of a lie

When a coordinate is classified hollow, the outcome is skipped, requires user context — not failed.

That distinction is load-bearing. Marking it failed would poison the install-failure rate with a structural condition that has nothing to do with package quality, and could trip automatic rollback against a version that is entirely fine. The attempt is also neutralised in the failure-rate calculation rather than counted.

The endpoint then holds that coordinate for six hours instead of re-running an expensive install every cycle that cannot possibly work.

Preventing it in the first place

Scope is treated as a property of the package, resolved through a precedence ladder — an operator pin first, then what the installer manifest declares, then the observed detonation footprint, and finally a default.

When a package resolves to user scope, the install is routed through an impersonated path that runs it as the actual logged-on user, in their session, with their environment. There is no SYSTEM fallback. If the user session cannot be resolved, the step returns a pending hold. Falling back to the SYSTEM path is precisely how the hollow install gets created, so the code refuses to do it even when that means doing nothing this cycle.

That impersonation is not a free-for-all. It requires a signed request — an ECDSA P-256 signature over a canonical field list, using a keypair dedicated to this purpose and separate from the device identity key — plus an explicit capability grant, plus a caller-authentication chain. After the user token is acquired and before the process is spawned, the token’s SID is compared against the requested SID and the launch is aborted on mismatch. If the signing key cannot be minted, no configuration is written at all, so the shim can never see an impersonation grant without a key to verify it.

Cleaning up what is already there

For the copies a previous tool left behind, there is a remediation that bypasses the package manager entirely. It reads the uninstall command out of the entry’s own registry key under HKU\S-1-5-18 and runs it directly, because that is the only thing that can reach it.

Where the entry provides a quiet uninstall string, that is used verbatim. Otherwise exactly two documented conventions are appended: MSI packages get the standard quiet and no-restart flags, and Chromium-family uninstallers get a forced-uninstall flag.

That last one was not a guess. Brave’s plain uninstall command hung twice on one of our endpoints, once for twenty minutes before we killed it. Chromium’s installer source explains why: without the force flag, the uninstaller calls IsChromeActiveOrUserCancelled, which launches the browser and blocks waiting for it to exit. The comment at the call site says as much — it is there to “show some UI dialog boxes”. In session 0 there is no desktop for those dialogs to appear on, and what we observed was a process that never came back. Chromium’s own flag reference describes --force-uninstall as uninstalling “without asking for any confirmation from user”, which is exactly the branch we need to skip. The remediation now runs under a five-minute bound and kills on timeout regardless.

The whole ceremony requires a typed confirmation naming the endpoint and the action, because unlike most operations here, this one removes software from someone’s machine.