diff --git a/orbit/pkg/table/ai_tools/app_row_test.go b/orbit/pkg/table/ai_tools/app_row_test.go new file mode 100644 index 00000000000..9452685aa72 --- /dev/null +++ b/orbit/pkg/table/ai_tools/app_row_test.go @@ -0,0 +1,46 @@ +package ai_tools + +import ( + "encoding/json" + "testing" + + "github.com/fleetdm/fleet/v4/orbit/pkg/table/ai_tools/internal/apps" +) + +// TestAppRow locks in the apps column semantics: `name` carries the installed +// program's real display name and `identifier` carries the known-app key, so a +// wrong match shows the real program instead of masquerading as the known app; +// the bundle id lives in `detail`. +func TestAppRow(t *testing.T) { + a := apps.App{ + Name: "claude-desktop", // known-app key + DisplayName: "Claude", + Vendor: "Anthropic", + BundleID: "com.anthropic.claude", + Version: "1.2.3", + Path: "/Applications/Claude.app", + PlatformSource: "applications", + Scope: "system", + Running: 1, + PID: 42, + SHA256: "deadbeef", + } + r := appRow(a) + + if r["type"] != "apps" || r["name"] != "Claude" || + r["identifier"] != "claude-desktop" || + r["location"] != "local" || r["source"] != "applications" || + r["version"] != "1.2.3" || r["path"] != "/Applications/Claude.app" || + r["running"] != "1" || r["pid"] != "42" || r["sha256"] != "deadbeef" { + t.Errorf("row columns wrong: %+v", r) + } + + var detail map[string]string + if err := json.Unmarshal([]byte(r["detail"]), &detail); err != nil { + t.Fatalf("detail not valid JSON: %v (%q)", err, r["detail"]) + } + if detail["vendor"] != "Anthropic" || detail["bundle_id"] != "com.anthropic.claude" || + detail["scope"] != "system" { + t.Errorf("detail wrong: %+v", detail) + } +} diff --git a/orbit/pkg/table/ai_tools/internal/apps/apps.go b/orbit/pkg/table/ai_tools/internal/apps/apps.go index 67b32899f18..8abcfe335c0 100644 --- a/orbit/pkg/table/ai_tools/internal/apps/apps.go +++ b/orbit/pkg/table/ai_tools/internal/apps/apps.go @@ -15,6 +15,7 @@ import ( // App is a detected AI desktop application. type App struct { Name string + DisplayName string Vendor string Path string BundleID string @@ -43,17 +44,17 @@ func knownApps() []knownApp { {"chatgpt", []string{"chatgpt"}, []string{"chatgpt"}, 0}, {"ollama", []string{"ollama"}, []string{"ollama"}, 11434}, {"lm-studio", []string{"lm studio", "lmstudio", "lm-studio"}, []string{"lm studio", "lm-studio", "lmstudio"}, 1234}, - {"jan", []string{"jan.app", "jan ", "/jan"}, []string{"jan"}, 1337}, + {"jan", []string{"jan"}, []string{"jan"}, 1337}, {"gpt4all", []string{"gpt4all"}, []string{"gpt4all"}, 0}, {"msty", []string{"msty"}, []string{"msty"}, 0}, {"anythingllm", []string{"anythingllm", "anything llm"}, []string{"anythingllm"}, 0}, - {"comet", []string{"comet.app", "comet "}, []string{"comet"}, 0}, // Perplexity Comet (AI browser) - {"dia", []string{"dia.app", "dia "}, []string{"dia"}, 0}, // Browser Company Dia (AI browser) + {"comet", []string{"comet"}, []string{"comet"}, 0}, // Perplexity Comet (AI browser) + {"dia", []string{"dia"}, []string{"dia"}, 0}, // Browser Company Dia (AI browser) {"perplexity", []string{"perplexity"}, []string{"perplexity"}, 0}, {"cursor", []string{"cursor"}, []string{"cursor"}, 0}, {"windsurf", []string{"windsurf"}, []string{"windsurf"}, 0}, {"antigravity", []string{"antigravity"}, []string{"antigravity"}, 0}, - {"trae", []string{"trae.app", "trae "}, []string{"trae"}, 0}, + {"trae", []string{"trae"}, []string{"trae"}, 0}, {"lm-studio-cli", []string{"lms"}, []string{"lms"}, 0}, } } @@ -62,6 +63,11 @@ func knownApps() []knownApp { func Scan(homesList []homes.Home, snap *proc.Snapshot) []App { out := scanApps(homesList) // platform-specific (build-tagged) for i := range out { + // Some discoveries carry no display name (e.g. a bare service binary); + // fall back to the known-app key rather than reporting a nameless row. + if out[i].DisplayName == "" { + out[i].DisplayName = out[i].Name + } k, ok := knownByName(out[i].Name) if !ok { continue @@ -83,11 +89,12 @@ func Scan(homesList []homes.Home, snap *proc.Snapshot) []App { } // containsWordBoundary reports whether want appears in pn delimited by -// non-alphanumeric characters (or the string edges). Several processNames tokens -// are short, common substrings (e.g. "dia", "jan"); a plain strings.Contains -// would falsely match unrelated processes like "mediaanalysisd", marking an app -// Running with an unrelated PID. It scans every occurrence so a bounded match -// later in the string is still found. +// non-alphanumeric characters (or the string edges). Several match and +// processNames tokens are short, common substrings (e.g. "dia", "jan", "lms"); +// a plain strings.Contains would falsely match unrelated programs like "NVIDIA +// Control Panel" or processes like "mediaanalysisd", reporting an AI app that +// was never installed or marking one Running with an unrelated PID. It scans +// every occurrence so a bounded match later in the string is still found. func containsWordBoundary(pn, want string) bool { if want == "" { return false @@ -112,12 +119,15 @@ func isAlnum(b byte) bool { return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') } -// matchKnown finds the AI app a set of identifying strings belongs to. +// matchKnown finds the AI app a set of identifying strings belongs to. Tokens +// are matched at word boundaries, never mid-word: the joining NUL and the +// non-alphanumeric characters of display names, bundle ids, and file names all +// delimit words. func matchKnown(tokens ...string) (knownApp, bool) { hay := strings.ToLower(strings.Join(tokens, "\x00")) for _, k := range knownApps() { for _, m := range k.match { - if strings.Contains(hay, m) { + if containsWordBoundary(hay, m) { return k, true } } @@ -125,10 +135,6 @@ func matchKnown(tokens ...string) (knownApp, bool) { return knownApp{}, false } -// firstNonEmpty is used by scanApps on darwin and linux; the windows build has -// no caller, so it is exempt from the unused check there. -// -//nolint:unused func firstNonEmpty(vals ...string) string { for _, v := range vals { if v != "" { diff --git a/orbit/pkg/table/ai_tools/internal/apps/apps_darwin.go b/orbit/pkg/table/ai_tools/internal/apps/apps_darwin.go index 6ad5a10f724..67f3abe54dc 100644 --- a/orbit/pkg/table/ai_tools/internal/apps/apps_darwin.go +++ b/orbit/pkg/table/ai_tools/internal/apps/apps_darwin.go @@ -49,6 +49,7 @@ func scanApps(homesList []homes.Home) []App { } out = append(out, App{ Name: k.name, + DisplayName: firstNonEmpty(info.BundleName, strings.TrimSuffix(e.Name(), ".app")), BundleID: info.BundleID, Version: firstNonEmpty(info.ShortVersion, info.BundleVersion), Path: appPath, diff --git a/orbit/pkg/table/ai_tools/internal/apps/apps_darwin_test.go b/orbit/pkg/table/ai_tools/internal/apps/apps_darwin_test.go index 77a59582cf8..b5f6d5beca7 100644 --- a/orbit/pkg/table/ai_tools/internal/apps/apps_darwin_test.go +++ b/orbit/pkg/table/ai_tools/internal/apps/apps_darwin_test.go @@ -46,4 +46,7 @@ func TestScanAppsRejectsExecutableTraversal(t *testing.T) { if msty.execPath != "" { t.Errorf("execPath = %q, want empty (traversal CFBundleExecutable must be rejected)", msty.execPath) } + if msty.DisplayName != "Msty" { + t.Errorf("DisplayName = %q, want the bundle's real name \"Msty\"", msty.DisplayName) + } } diff --git a/orbit/pkg/table/ai_tools/internal/apps/apps_linux.go b/orbit/pkg/table/ai_tools/internal/apps/apps_linux.go index dd2628306e5..1c0cb1b5a9a 100644 --- a/orbit/pkg/table/ai_tools/internal/apps/apps_linux.go +++ b/orbit/pkg/table/ai_tools/internal/apps/apps_linux.go @@ -32,6 +32,7 @@ func scanApps(homesList []homes.Home) []App { seen[ka.name] = struct{}{} out = append(out, App{ Name: ka.name, + DisplayName: name, Path: firstNonEmpty(exec, e.Name()), PlatformSource: "desktop-file", Scope: scope, diff --git a/orbit/pkg/table/ai_tools/internal/apps/apps_test.go b/orbit/pkg/table/ai_tools/internal/apps/apps_test.go index 9c7743e3590..7322e491c89 100644 --- a/orbit/pkg/table/ai_tools/internal/apps/apps_test.go +++ b/orbit/pkg/table/ai_tools/internal/apps/apps_test.go @@ -21,6 +21,29 @@ func TestMatchKnown(t *testing.T) { {[]string{"Comet.app", "Comet", "ai.perplexity.comet"}, true, "comet", 0}, {[]string{"Dia.app", "Dia", "company.thebrowser.dia"}, true, "dia", 0}, {[]string{"Perplexity.app", "Perplexity", "ai.perplexity.macos"}, true, "perplexity", 0}, + + // Windows uninstall entries and MSIX packages carry a bare display name + // with no trailing delimiter; a genuine install must still match. + {[]string{"Dia"}, true, "dia", 0}, + {[]string{"Jan"}, true, "jan", 1337}, + {[]string{"Comet"}, true, "comet", 0}, + {[]string{"Trae"}, true, "trae", 0}, + {[]string{"lms"}, true, "lm-studio-cli", 0}, + + // A bounded occurrence after a mid-word one must still match: the + // boundary scan may not stop at the first hit. + {[]string{"NVIDIA Dia"}, true, "dia", 0}, + + // Short tokens ("dia", "lms") must not match mid-word inside unrelated + // software names. + {[]string{"NVIDIA Control Panel"}, false, "", 0}, + {[]string{"NVIDIA Graphics Driver 591.86"}, false, "", 0}, + {[]string{"NVIDIA Install Application"}, false, "", 0}, + {[]string{"VLC media player"}, false, "", 0}, + {[]string{"Plex Media Server 1.43.1.10611 (x64)"}, false, "", 0}, + {[]string{"vs_minshellmsi"}, false, "", 0}, + {[]string{"vs_minshellmsires"}, false, "", 0}, + {[]string{"Windows Media Player.app"}, false, "", 0}, } for _, c := range cases { k, ok := matchKnown(c.tokens...) diff --git a/orbit/pkg/table/ai_tools/internal/apps/apps_windows.go b/orbit/pkg/table/ai_tools/internal/apps/apps_windows.go index e9491dc8982..890a5bfd2f9 100644 --- a/orbit/pkg/table/ai_tools/internal/apps/apps_windows.go +++ b/orbit/pkg/table/ai_tools/internal/apps/apps_windows.go @@ -101,6 +101,7 @@ func scanApps(homesList []homes.Home) []App { } c.add(appCandidate{ MatchTokens: []string{display}, + DisplayName: display, Vendor: pub, Version: version, Path: loc, diff --git a/orbit/pkg/table/ai_tools/internal/apps/appx.go b/orbit/pkg/table/ai_tools/internal/apps/appx.go index 37f069a1062..787ccf0e2cc 100644 --- a/orbit/pkg/table/ai_tools/internal/apps/appx.go +++ b/orbit/pkg/table/ai_tools/internal/apps/appx.go @@ -159,6 +159,7 @@ func scanAppxDirs(c *appCollector, installRoot string, userPkgDirs []string) { } c.add(appCandidate{ MatchTokens: []string{pkg.Name, appxLiteral(man.Properties.DisplayName)}, + DisplayName: firstNonEmpty(appxLiteral(man.Properties.DisplayName), pkg.Name), Vendor: appxVendor(man.Properties.PublisherDisplayName, man.Identity.Publisher), Version: pkg.Version, Path: dir, diff --git a/orbit/pkg/table/ai_tools/internal/apps/appx_scan_test.go b/orbit/pkg/table/ai_tools/internal/apps/appx_scan_test.go index 5222f4aaf09..885d4b3b9d7 100644 --- a/orbit/pkg/table/ai_tools/internal/apps/appx_scan_test.go +++ b/orbit/pkg/table/ai_tools/internal/apps/appx_scan_test.go @@ -49,7 +49,7 @@ func TestScanAppxDirsInstallRoot(t *testing.T) { t.Fatalf("got %d apps, want 1: %+v", len(got), got) } want := App{ - Name: "chatgpt", Vendor: "OpenAI", Version: "1.2026.190.0", + Name: "chatgpt", DisplayName: "ChatGPT", Vendor: "OpenAI", Version: "1.2026.190.0", Path: dir, Scope: "system", PlatformSource: "appx", } if got[0] != want { @@ -198,10 +198,19 @@ func TestScanAppxDirsStaleUserDirIsNotAnInstall(t *testing.T) { } } -// TestScanAppxDirsMissingManifest covers the manifest being unreadable, which -// must cost only the vendor, never the row. +// TestScanAppxDirsMissingManifest covers a manifest that is unreadable or +// carries no usable display name (including a MUI indirect string), which must +// cost only the vendor and the friendly name, never the row: the display name +// falls back to the package identity name. func TestScanAppxDirsMissingManifest(t *testing.T) { - for _, manifest := range []string{"", "not xml at all <<<", ""} { + const indirectManifest = ` + + + @{OpenAI.ChatGPT-Desktop_1.2026.190.0_arm64__2p2nqsd0c76g0?ms-resource://OpenAI.ChatGPT-Desktop/Resources/AppName} + +` + + for _, manifest := range []string{"", "not xml at all <<<", "", indirectManifest} { root := t.TempDir() mkPackageDir(t, root, chatGPTPFN, manifest) @@ -218,6 +227,9 @@ func TestScanAppxDirsMissingManifest(t *testing.T) { if got[0].Vendor != "" { t.Errorf("manifest=%q: got vendor %q, want empty", manifest, got[0].Vendor) } + if got[0].DisplayName != "OpenAI.ChatGPT-Desktop" { + t.Errorf("manifest=%q: got display name %q, want the package identity name", manifest, got[0].DisplayName) + } } } diff --git a/orbit/pkg/table/ai_tools/internal/apps/appx_test.go b/orbit/pkg/table/ai_tools/internal/apps/appx_test.go index 36d1fbe8f36..17550f10caf 100644 --- a/orbit/pkg/table/ai_tools/internal/apps/appx_test.go +++ b/orbit/pkg/table/ai_tools/internal/apps/appx_test.go @@ -210,7 +210,9 @@ func TestMatchKnownPackageNames(t *testing.T) { }{ {"OpenAI.ChatGPT-Desktop", true, "chatgpt"}, {"ElementLabs.LMStudio", true, "lm-studio"}, - {"Perplexity.Comet", true, "perplexity"}, + // The "." delimits a word, so the "comet" token sees the product segment + // and wins over the publisher-only "perplexity" match. + {"Perplexity.Comet", true, "comet"}, {"Microsoft.WindowsCalculator", false, ""}, {"Microsoft.VCLibs.140.00", false, ""}, diff --git a/orbit/pkg/table/ai_tools/internal/apps/collect.go b/orbit/pkg/table/ai_tools/internal/apps/collect.go index de15fa3d62d..cc9ea876c36 100644 --- a/orbit/pkg/table/ai_tools/internal/apps/collect.go +++ b/orbit/pkg/table/ai_tools/internal/apps/collect.go @@ -13,6 +13,7 @@ package apps // matched against the known-app list and deduplicated. type appCandidate struct { MatchTokens []string // identifying strings matched against knownApps + DisplayName string Vendor string Version string Path string @@ -66,6 +67,7 @@ func (c *appCollector) add(cand appCandidate) bool { c.seen[k.name] = struct{}{} c.out = append(c.out, App{ Name: k.name, + DisplayName: cand.DisplayName, Vendor: cand.Vendor, Version: cand.Version, Path: cand.Path, diff --git a/orbit/pkg/table/ai_tools/internal/apps/collect_test.go b/orbit/pkg/table/ai_tools/internal/apps/collect_test.go index 0822a9619d6..b322082dca8 100644 --- a/orbit/pkg/table/ai_tools/internal/apps/collect_test.go +++ b/orbit/pkg/table/ai_tools/internal/apps/collect_test.go @@ -53,6 +53,43 @@ func TestAppCollectorSharedAcrossSources(t *testing.T) { } } +// TestAppCollectorImpostorDoesNotMaskRealInstall covers the masking hazard of +// first-match-wins dedup: an unrelated system-wide program whose name merely +// contains a known-app token must not match at all, so it can never claim the +// slot of a genuine per-user install of that app scanned later. +func TestAppCollectorImpostorDoesNotMaskRealInstall(t *testing.T) { + c := newAppCollector() + if c.add(appCandidate{ + MatchTokens: []string{"NVIDIA Control Panel"}, + Vendor: "NVIDIA Corporation", + Version: "8.1.969.0", + Path: `C:\Program Files\NVIDIA Corporation\Control Panel Client`, + Scope: "system", + Source: "registry", + }) { + t.Error("NVIDIA Control Panel was collected as an AI app") + } + if !c.add(appCandidate{ + MatchTokens: []string{"Dia"}, + DisplayName: "Dia", + Vendor: "The Browser Company", + Version: "1.0.0", + Path: `C:\Users\alice\AppData\Local\Programs\Dia`, + Scope: "user", + Source: "registry", + }) { + t.Error("genuine per-user Dia install was not collected") + } + + got := c.apps() + if len(got) != 1 { + t.Fatalf("got %d apps, want 1: %+v", len(got), got) + } + if got[0].Name != "dia" || got[0].DisplayName != "Dia" || got[0].Scope != "user" || got[0].Vendor != "The Browser Company" { + t.Errorf("got %+v, want the genuine user-scoped Dia install", got[0]) + } +} + func TestAppCollectorSkipsUnknown(t *testing.T) { c := newAppCollector() for _, tokens := range [][]string{ @@ -96,6 +133,7 @@ func TestAppCollectorPreservesOrderAndFields(t *testing.T) { c := newAppCollector() c.add(appCandidate{ MatchTokens: []string{"Ollama"}, + DisplayName: "Ollama", Vendor: "Ollama Inc.", Version: "0.5.7", Path: `C:\Users\alice\AppData\Local\Programs\Ollama`, @@ -112,7 +150,7 @@ func TestAppCollectorPreservesOrderAndFields(t *testing.T) { t.Errorf("got order [%q %q], want [\"ollama\" \"cursor\"]", got[0].Name, got[1].Name) } want := App{ - Name: "ollama", Vendor: "Ollama Inc.", Version: "0.5.7", + Name: "ollama", DisplayName: "Ollama", Vendor: "Ollama Inc.", Version: "0.5.7", Path: `C:\Users\alice\AppData\Local\Programs\Ollama`, Scope: "user", PlatformSource: "registry", } if got[0] != want { diff --git a/orbit/pkg/table/ai_tools/tables.go b/orbit/pkg/table/ai_tools/tables.go index d27ac58c5fe..a31037af435 100644 --- a/orbit/pkg/table/ai_tools/tables.go +++ b/orbit/pkg/table/ai_tools/tables.go @@ -48,7 +48,7 @@ var allTypes = []string{"mcp_server", "ide_plugins", "agents", "apps", "sockets" var columns = []string{ "type", // mcp_server | ide_plugins | agents | apps | sockets | agent_instruction | browser_extension "name", // server/plugin/agent/app/process/instruction-file name - "identifier", // plugin_id | bundle_id | mcp server name | agent binary | socket service + "identifier", // plugin_id | known-app key | mcp server name | agent binary | socket service "category", // classification bucket (coding-assistant, agent-runtime, ai-api-egress, ...) "location", // local | remote "source", // provenance: client | editor | install_method | platform_source | direction | tool @@ -301,8 +301,8 @@ func agentRow(a agents.Agent) map[string]string { func appRow(a apps.App) map[string]string { return row(map[string]string{ "type": "apps", - "name": a.Name, - "identifier": a.BundleID, + "name": a.DisplayName, + "identifier": a.Name, "location": "local", "source": a.PlatformSource, "version": a.Version,