Skip to content
Closed
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
55 changes: 51 additions & 4 deletions .claude/skills/new-fma/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ Real examples from this codebase where the metadata lied:

```bash
brew install msitools # provides msiinfo for MSI inspection (macOS dev box)
brew install sevenzip # provides 7zz, for listing NSIS installer payloads
brew install innoextract # Inno installers — only supports up to Inno 6.0.5, see toolkit #4
gh auth status # gh CLI for reading winget-pkgs manifests
```

Expand Down Expand Up @@ -76,7 +78,44 @@ hdiutil detach "$MP" >/dev/null; rm -f app.dmg
```
(For pkg-format casks, the bundle id is harder to read offline — the cask `zap`/`uninstall` `pkgutil`/`launchctl`/`savedState` paths are strong hints, e.g. `<bundleid>.savedState`.)

### 4. Silent install/uninstall flags — use documented sources, never guess
### 4. Capture the process names (Windows only) — for the `open` query

Windows FMAs need `process_names`: the executables the app runs as. Fleet uses them to build the `open` query that stops a patch from landing on top of a running app. **macOS needs nothing here** — its open query joins `apps.path` to `processes.path`, so the bundle id already covers it. Windows has no equivalent (`programs.install_location` is unreliable), so the names must be captured by hand.

Get them from the installer you already downloaded for identity verification, by installer type:

```bash
# msi — Shortcut table names the primary exe; File table lists all of them
msiinfo export app.msi Shortcut | awk -F'\t' '{print $3"\t"$5}' # → "7-Zip File Manager [#_7zFM.exe]"
msiinfo export app.msi File | awk -F'\t' 'tolower($3) ~ /\.exe/ {print $3"\t"$4}' | sort -k2 -rn

# msix — AppxManifest is authoritative (msix is a zip). Check content-length first, these are big.
unzip -p app.msix AppxManifest.xml | tr '>' '>\n' | grep -i "<Application .*Executable="

# exe / nullsoft (NSIS)
7zz l app.exe | grep -iE '\.exe$'

# exe / inno
innoextract -l -m app.exe | grep -iE '\.exe$'

# zip / portable — read NestedInstallerFiles in the winget manifest, or just list it
unzip -l app.zip | grep -iE '\.exe$'
```

Verified behavior of each (don't re-derive):
| Type | Tool | Notes |
|------|------|-------|
| msi | `msiinfo` | Reliable. `Shortcut.Target` (`[#_7zFM.exe]`) is the user-facing exe; `File` catches helpers. |
| msix | `unzip` | Reliable and exact — `Executable="app\Slack.exe"`. Installers are often 100 MB+. |
| exe (nullsoft) | `7zz` | Works. `7zz`, not `7z` — the Homebrew formula is `sevenzip`. |
| exe (inno) | `innoextract` | **Only up to Inno 6.0.5.** 1.9 is the current Homebrew version and fails on Inno 6.3 ("Unexpected setup data version") — which most current installers are. `7zz` can't read Inno either. |
| exe (burn/bootstrapper) | — | Not readable offline; the payload is a nested MSI chain. |

When the installer won't open (modern Inno, burn, or a download too large to justify), don't guess. Either use the vendor's docs, or ship without `process_names` and read the primary exe off the validator run — it installs the app on a real Windows host, so the ARP `DisplayIcon` and the contents of `InstallLocation` are visible in its log.

Picking names once you have the list: include what the **user** has open (the GUI exe, plus separately-named variants like `Code - Insiders.exe`), not every helper. Background updaters (`GUP.exe`) and short-lived CLIs (`7z.exe`) are noise — they'd make the app look permanently open. If an app spawns a swarm of same-prefixed helpers, use one prefix entry: `"1password*"`.

### 5. Silent install/uninstall flags — use documented sources, never guess
- The winget installer manifest's `InstallerSwitches` (`Silent`, `Custom`) is the first source.
- **silentinstallhq.com** has per-app guides with the exact switches (e.g. GoToMeeting uses `/silent`, not `/S`). Use `WebFetch` on `https://silentinstallhq.com/<app>-silent-install-how-to-guide/`.
- Cross-check the vendor's own docs.
Expand All @@ -92,12 +131,14 @@ hdiutil detach "$MP" >/dev/null; rm -f app.dmg
### Windows (winget)
1. Read the winget manifests (toolkit #1). Pick **machine** scope, **x64** (or the only arch available — some apps are x86-only).
2. **Inspect the MSI** (toolkit #2) to confirm DisplayName, Publisher, version, codes, and to detect bootstrappers.
3. Create `ee/maintained-apps/inputs/winget/<slug-name>.json`:
3. **Capture the process names** (toolkit #4) from the same installer, while you have it.
4. Create `ee/maintained-apps/inputs/winget/<slug-name>.json`:
- `name` (catalog display, can be friendly), `slug` (`<app>/windows`), `package_identifier`, `unique_identifier` (= verified DisplayName), `installer_arch`, `installer_type`, `installer_scope`, `default_categories`.
- `process_names` (verified in step 3).
- `program_publisher` if registry Publisher ≠ winget locale Publisher.
- `fuzzy_match_name` / `exists_query` as needed (below).
- `install_script_path` / `uninstall_script_path` for any non-MSI-machine installer.
4. Generate, add description, check icon.
5. Generate, add description, check icon.

### Installer type mapping (winget `InstallerType` → FMA `installer_type` + silent flags)
| winget type | FMA type | install silent | uninstall |
Expand Down Expand Up @@ -129,6 +170,7 @@ go run cmd/maintained-apps/main.go --slug="<app>/<platform>" --debug
| `program_publisher` (winget) | Overrides the exists-query publisher when registry Publisher ≠ winget locale Publisher. |
| `fuzzy_match_name` (winget) | `true` → `name LIKE '<unique_identifier> %'`. A string → `name LIKE '<that string>'` verbatim (e.g. `"Mozilla Firefox % ESR %"`, `"IntelliJ IDEA 20%"`). |
| `exists_query` (winget) | Replaces the generated exists query verbatim. The patched query is DERIVED from it (appends `AND version_compare(...) < 0`). |
| `process_names` (winget) | Executables the app runs as, e.g. `["7zFM.exe","7zG.exe"]`. Builds the `open` query. An entry may end in `*` for a prefix match (`"1password*"`). Every entry must end in `.exe` or `*`, with no path — the ingester hard-errors otherwise. Windows only; darwin derives it from the bundle id. |
| `installer_scope` | Must match the winget manifest's Scope — you can't pick machine if only user exists. |

`patch_policy_path` exists in the input struct but is **dead code** (unused since the patched query became auto-generated). Don't use it; there is no patched-query override other than shaping `exists_query` or a hard-coded per-app branch in the ingester (Docker Desktop precedent).
Expand Down Expand Up @@ -158,11 +200,16 @@ if ($u -match '^\s*"([^"]+)"\s*(.*)$') { # quoted
- Corretto 21 and 25 both register as `Amazon Corretto (x64)` — pin each with `exists_query ... AND version LIKE '<major>.%'`.
- IntelliJ Ultimate's DisplayName `IntelliJ IDEA <ver>` also matches Community's `IntelliJ IDEA Community Edition <ver>` — exclude siblings in `exists_query` (`AND name NOT LIKE 'IntelliJ IDEA Community%'`) or use a custom `fuzzy_match_name` pattern.

**7. Non-pinned installer URLs.** Some manifests point at a "latest" redirect (e.g. `link.gotomeeting.com/latest-msi`). The pinned SHA drifts when the vendor ships a new build, breaking Fleet installs until the FMA auto-update bumps it. Note this in the PR.
**7. A wrong process name fails green.** The open query is `SELECT 1 WHERE NOT EXISTS (... FROM processes WHERE <name predicate>)`. If the predicate names a process that never exists, `NOT EXISTS` is always true, so the app always reads as "closed" and the patch installs over a running app — no error, anywhere. That's why `process_names` is worth the extra minute with the installer, and why the ingester rejects malformed entries instead of correcting them.

The `<name>.exe` fallback only fires for **single-word** catalog names; a multi-word name with no `process_names` and no override emits **no open query at all** (better no gate than a fake one). So for any app whose name contains a space, `process_names` is the only way it gets this feature.

**8. Non-pinned installer URLs.** Some manifests point at a "latest" redirect (e.g. `link.gotomeeting.com/latest-msi`). The pinned SHA drifts when the vendor ships a new build, breaking Fleet installs until the FMA auto-update bumps it. Note this in the PR.

## Pre-ship checklist
- [ ] Identity fields verified against the real installer (MSI Property table / Info.plist), not guessed.
- [ ] `unique_identifier` = registry DisplayName / bundle id; `program_publisher` set if needed.
- [ ] Windows: `process_names` captured from the installer (toolkit #4), or its absence explained in the PR.
- [ ] Silent install/uninstall flags from winget `InstallerSwitches` or silentinstallhq, not invented.
- [ ] Custom uninstall (non-MSI-machine) uses the defensive UninstallString parser.
- [ ] Version reconciles with osquery (or a documented validator exception applies — not a blanket skip).
Expand Down
1 change: 1 addition & 0 deletions changes/windows-fma-process-names-input
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Added a `process_names` field to Windows Fleet-maintained app inputs, so an app's "app open" pre-install check is built from the executables verified in its installer instead of a guessed `<name>.exe`.
1 change: 1 addition & 0 deletions changes/windows-open-query-drop-multiword-guess
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Fixed Fleet-maintained apps on Windows generating an "app open" pre-install check that could never match: multi-word app names without a known process name (e.g. "Mozilla Firefox", "Microsoft Visual C++ 2015-2022 Redistributable (x64)") no longer produce a guessed `<name>.exe` process check. Apps with a curated process-name mapping keep their check.
1 change: 1 addition & 0 deletions ee/maintained-apps/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ go run cmd/maintained-apps/main.go --slug="box-drive/windows" --debug
| `install_script_path` | string | Filepath to a custom install script (`.ps1`). Overrides the generated install script. Script must be placed in `inputs/winget/scripts/`. For `.msi` apps, the ingestor automatically generates install scripts. Do not add scripts unless you need to override the generated behavior. For `.exe` apps, you must provide PowerShell scripts that run the installer file directly. Fleet stores the installer and sends it to the host at install time; your script must execute it using the `INSTALLER_PATH` environment variable. |
| `uninstall_script_path` | string | Filepath to a custom uninstall script (`.ps1`). Overrides the generated uninstall script. Script must be placed in `inputs/winget/scripts/`. For `.msi` apps, the ingestor automatically generates uninstall scripts. Do not add scripts unless you need to override the generated behavior. For `.exe` apps, you must provide a script to uninstall the app. Scripts for `.exe` apps are vendor-specific. Use the vendor’s documented silent uninstall switch or the registered UninstallString (if available), ensuring the script runs silently and returns the installer’s exit code. |
| `fuzzy_match_name` | boolean | If the `unique_identifier` doesn't match the `DisplayName`, use `fuzzy_match_name` to specify that Fleet uses "fuzzy matching" to match the Fleet-maintained app and the inventoried software. For example, for Pritunl, the `unique_identifier` is "Pritunl" and the inventories software's `DisplayName` is "Pritunl Client". With `fuzzy_match_name` set to true, Pritunl app will be matched to the inventories software. |
| `process_names` | array of strings | The executables the app runs as, e.g. `["7zFM.exe", "7zG.exe"]`. Fleet uses these to detect that the app is open, so it can hold a patch back until the user closes it. Read them from the installer: `msiinfo export app.msi File` (msi), `unzip -p app.msix AppxManifest.xml` (msix), or `7zz l app.exe` (NSIS exe). List the executables a user actually has open, not background updaters. An entry may end in `*` to match a prefix (e.g. `"1password*"`) for apps that run many differently-named helper processes. Every entry must end in `.exe` or `*`, and must be a bare file name with no directory. If omitted, Fleet falls back to `<name>.exe` for single-word app names, and generates no check at all for multi-word names — so an app like "Mozilla Firefox" only gets this feature if you set this field. |
| `requires_client_os` | boolean | Set to `true` when the installer refuses to run on Windows Server SKUs (e.g., Dell Display and Peripheral Manager). Fleet's ingestion ignores this field; CI reads it to route validation to the `windows-11-arm` runner (the only GitHub-hosted client-OS Windows runner) instead of the default Windows Server x64 runner. |

#### Windows troubleshooting
Expand Down
4 changes: 3 additions & 1 deletion ee/maintained-apps/ingesters/homebrew/ingester.go
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,9 @@ func (i *brewIngester) ingestOne(ctx context.Context, input inputApp) (*maintain
}
}

out.Queries.Open = patch_policy.GenerateOpenQuery("darwin", out.UniqueIdentifier, "")
// Process names are a windows-only input: darwin resolves running processes from the app
// bundle's install path, so it needs no per-app data.
out.Queries.Open = patch_policy.GenerateOpenQuery("darwin", out.UniqueIdentifier, "", nil)

return out, nil
}
Expand Down
13 changes: 12 additions & 1 deletion ee/maintained-apps/ingesters/winget/ingester.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ func IngestApps(ctx context.Context, logger *slog.Logger, inputsPath string, slu
return nil, ctxerr.NewWithData(ctx, "missing package identifier for app", map[string]any{"file_name": f.Name()})
}

if err := patch_policy.ValidateProcessNames(input.ProcessNames); err != nil {
return nil, ctxerr.WrapWithData(ctx, err, "invalid process_names for app", map[string]any{"file_name": f.Name()})
}

if slugFilter != "" && !strings.Contains(input.Slug, slugFilter) {
continue
}
Expand Down Expand Up @@ -479,7 +483,7 @@ func (i *wingetIngester) ingestOne(ctx context.Context, input inputApp) (*mainta
return nil, ctxerr.Wrap(ctx, err, "creating patch policy")
}

out.Queries.Open = patch_policy.GenerateOpenQuery("windows", "", out.Name)
out.Queries.Open = patch_policy.GenerateOpenQuery("windows", "", out.Name, input.ProcessNames)

return &out, nil
}
Expand Down Expand Up @@ -654,6 +658,13 @@ type inputApp struct {
DefaultCategories []string `json:"default_categories"`
Frozen bool `json:"frozen"`
PatchPolicyPath string `json:"patch_policy_path"`
// ProcessNames lists the executables the app runs as, verified from the installer (MSI File
// or Shortcut table, MSIX AppxManifest, Inno/NSIS header). It drives the "open" query that
// gates patching a running app. An entry may end in "*" for a prefix match, for apps with
// many helper processes (e.g. "1password*"). When unset, the app falls back to the curated
// overrides, then to guessing "<name>.exe" for single-word names only — so a multi-word app
// with no process_names gets no gate at all.
ProcessNames []string `json:"process_names,omitempty"`
// RequiresClientOS marks installers that refuse to run on Windows Server
// SKUs (e.g. Dell Display and Peripheral Manager). The ingester ignores it;
// CI (.github/scripts/partition-fma-apps.sh) reads it to route validation to
Expand Down
1 change: 1 addition & 0 deletions ee/maintained-apps/inputs/winget/7-zip.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@
"installer_arch": "x64",
"installer_type": "msi",
"installer_scope": "machine",
"process_names": ["7zFM.exe", "7zG.exe"],
"default_categories": ["Productivity"]
}
2 changes: 1 addition & 1 deletion ee/maintained-apps/maintained_apps.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const OutputPath = "ee/maintained-apps/outputs"
type FMAQueries struct {
Exists string `json:"exists"`
Patched string `json:"patched"`
Open string `json:"open"`
Open string `json:"open,omitempty"`
}

type FMAManifestApp struct {
Expand Down
3 changes: 2 additions & 1 deletion ee/maintained-apps/outputs/7-zip/windows.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
"version": "26.02",
"queries": {
"exists": "SELECT 1 FROM programs WHERE name LIKE '7-Zip %' AND publisher = 'Igor Pavlov';",
"patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM programs WHERE name LIKE '7-Zip %' AND publisher = 'Igor Pavlov' AND version_compare(version, '26.02') < 0);"
"patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM programs WHERE name LIKE '7-Zip %' AND publisher = 'Igor Pavlov' AND version_compare(version, '26.02') < 0);",
"open": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM processes WHERE LOWER(name) IN ('7zfm.exe','7zg.exe'));"
},
"installer_url": "https://github.com/ip7z/7zip/releases/download/26.02/7z2602-x64.msi",
"install_script_ref": "22e48c46",
Expand Down
Loading
Loading