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,