Skip to content
Merged
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
46 changes: 46 additions & 0 deletions orbit/pkg/table/ai_tools/app_row_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
36 changes: 21 additions & 15 deletions orbit/pkg/table/ai_tools/internal/apps/apps.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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},
}
}
Expand All @@ -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
Expand All @@ -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
Expand All @@ -112,23 +119,22 @@ 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
}
}
}
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 != "" {
Expand Down
1 change: 1 addition & 0 deletions orbit/pkg/table/ai_tools/internal/apps/apps_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions orbit/pkg/table/ai_tools/internal/apps/apps_darwin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
1 change: 1 addition & 0 deletions orbit/pkg/table/ai_tools/internal/apps/apps_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
23 changes: 23 additions & 0 deletions orbit/pkg/table/ai_tools/internal/apps/apps_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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...)
Expand Down
1 change: 1 addition & 0 deletions orbit/pkg/table/ai_tools/internal/apps/apps_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ func scanApps(homesList []homes.Home) []App {
}
c.add(appCandidate{
MatchTokens: []string{display},
DisplayName: display,
Vendor: pub,
Version: version,
Path: loc,
Expand Down
1 change: 1 addition & 0 deletions orbit/pkg/table/ai_tools/internal/apps/appx.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
20 changes: 16 additions & 4 deletions orbit/pkg/table/ai_tools/internal/apps/appx_scan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 <<<", "<Package><Identity/></Package>"} {
const indirectManifest = `<?xml version="1.0" encoding="utf-8"?>
<Package xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10">
<Properties>
<DisplayName>@{OpenAI.ChatGPT-Desktop_1.2026.190.0_arm64__2p2nqsd0c76g0?ms-resource://OpenAI.ChatGPT-Desktop/Resources/AppName}</DisplayName>
</Properties>
</Package>`

for _, manifest := range []string{"", "not xml at all <<<", "<Package><Identity/></Package>", indirectManifest} {
root := t.TempDir()
mkPackageDir(t, root, chatGPTPFN, manifest)

Expand All @@ -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)
}
}
}

Expand Down
4 changes: 3 additions & 1 deletion orbit/pkg/table/ai_tools/internal/apps/appx_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, ""},
Expand Down
2 changes: 2 additions & 0 deletions orbit/pkg/table/ai_tools/internal/apps/collect.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
40 changes: 39 additions & 1 deletion orbit/pkg/table/ai_tools/internal/apps/collect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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`,
Expand All @@ -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 {
Expand Down
6 changes: 3 additions & 3 deletions orbit/pkg/table/ai_tools/tables.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Comment thread
juan-fdz-hawa marked this conversation as resolved.
"identifier": a.Name,
"location": "local",
"source": a.PlatformSource,
"version": a.Version,
Expand Down
Loading