This is an automated email from the ASF dual-hosted git repository.

hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git


The following commit(s) were added to refs/heads/master by this push:
     new 78687bb  feat: add cross-platform AI agent detection- #192 (#192)
78687bb is described below

commit 78687bb1878275911cd8b72fcec315c37cc61cdf
Author: Paperlz <[email protected]>
AuthorDate: Thu Aug 13 15:04:22 2026 +0800

    feat: add cross-platform AI agent detection- #192 (#192)
---
 agent/agent.go                         |  26 +++
 agent/codex_scan_unix.go               | 146 +++++++++++++++
 agent/codex_windows_shared.go          |  43 +++++
 agent/codex_windows_shared_test.go     |  56 ++++++
 agent/fingerprint.go                   |  40 ++++
 agent/fingerprint_load.go              | 146 +++++++++++++++
 agent/fingerprint_load_test.go         | 125 +++++++++++++
 agent/fingerprints/claude-code.json    |  10 +
 agent/fingerprints/claude-desktop.json |   7 +
 agent/fingerprints/codex-cli.json      |   9 +
 agent/fingerprints/codex.json          |   6 +
 agent/fingerprints/cursor-agent.json   |   7 +
 agent/fingerprints/cursor.json         |   7 +
 agent/fingerprints/openagent.json      |  17 ++
 agent/fingerprints/openclaw.json       |   8 +
 agent/fingerprints/windsurf.json       |   7 +
 agent/scan.go                          | 102 ++++++++++
 agent/scan_common.go                   | 188 +++++++++++++++++++
 agent/scan_darwin.go                   | 130 +++++++++++++
 agent/scan_linux.go                    | 216 +++++++++++++++++++++
 agent/scan_localserver.go              |  75 ++++++++
 agent/scan_unix.go                     | 109 +++++++++++
 agent/scan_windows.go                  | 333 +++++++++++++++++++++++++++++++++
 agent/scan_windows_desktop.go          | 153 +++++++++++++++
 agent/version.go                       | 127 +++++++++++++
 agent/version_windows.go               |  73 ++++++++
 controllers/agent.go                   |  32 ++++
 go.mod                                 |  92 ++++++++-
 go.sum                                 |  17 ++
 internal/hermes/inspect.go             | 165 ++++++++++++++++
 localserver/listener_darwin.go         |  71 +++++++
 localserver/listener_linux.go          | 141 ++++++++++++++
 localserver/listener_windows.go        | 178 ++++++++++++++++++
 localserver/localserver.go             | 146 +++++++++++++++
 routers/router.go                      |   1 +
 web/src/AgentsPage.js                  | 130 +++++++++++++
 web/src/App.js                         |  15 +-
 web/src/backend/AgentBackend.js        |  23 +++
 web/src/components/AgentIcon.js        |  56 ++++++
 web/src/locales/de/data.json           |  10 +
 web/src/locales/en/data.json           |  10 +
 web/src/locales/es/data.json           |  10 +
 web/src/locales/fr/data.json           |  10 +
 web/src/locales/id/data.json           |  10 +
 web/src/locales/ja/data.json           |  10 +
 web/src/locales/ko/data.json           |  10 +
 web/src/locales/ru/data.json           |  10 +
 web/src/locales/zh/data.json           |  10 +
 48 files changed, 3321 insertions(+), 2 deletions(-)

diff --git a/agent/agent.go b/agent/agent.go
new file mode 100644
index 0000000..c8fca24
--- /dev/null
+++ b/agent/agent.go
@@ -0,0 +1,26 @@
+// Copyright 2025 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package agent detects AI agents in known installation locations.
+package agent
+
+// Installation describes an AI agent installation found on the host.
+type Installation struct {
+       AgentId       string `json:"agentId"`
+       Name          string `json:"name"`
+       Version       string `json:"version,omitempty"`
+       Path          string `json:"path"`
+       InstallMethod string `json:"installMethod"`
+       Owner         string `json:"owner"`
+}
diff --git a/agent/codex_scan_unix.go b/agent/codex_scan_unix.go
new file mode 100644
index 0000000..997d7f2
--- /dev/null
+++ b/agent/codex_scan_unix.go
@@ -0,0 +1,146 @@
+// Copyright 2025 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//go:build linux || darwin
+
+package agent
+
+import (
+       "bytes"
+       "io"
+       "os"
+       "path/filepath"
+       "runtime"
+       "strings"
+)
+
+var systemBinDirs = []string{"/usr/bin", "/usr/local/bin"}
+
+func scanCodexStandalone() []Installation {
+       var result []Installation
+       for _, dir := range systemBinDirs {
+               executable := filepath.Join(dir, "codex")
+               info, err := os.Stat(executable)
+               if err != nil || !info.Mode().IsRegular() || info.Mode()&0o111 
== 0 ||
+                       !isCodexNativeBinary(executable) {
+                       continue
+               }
+               result = append(result, Installation{
+                       AgentId: "codex-cli", Name: "Codex CLI", Path: 
executable,
+                       InstallMethod: "standalone", Owner: 
fileOwner(executable),
+               })
+       }
+       return result
+}
+
+func isCodexNativeBinary(path string) bool {
+       if resolved, err := filepath.EvalSymlinks(path); err == nil {
+               normalized := strings.ToLower(filepath.ToSlash(resolved))
+               if strings.Contains(normalized, "/node_modules/@openai/codex/") 
||
+                       strings.Contains(normalized, "/caskroom/codex/") ||
+                       strings.Contains(normalized, "/caskroom/codex@latest/") 
{
+                       return false
+               }
+       }
+       file, err := os.Open(path)
+       if err != nil {
+               return false
+       }
+       defer file.Close()
+
+       magic := make([]byte, 4)
+       if _, err := io.ReadFull(file, magic); err != nil {
+               return false
+       }
+       if bytes.Equal(magic, []byte{0x7f, 'E', 'L', 'F'}) {
+               return true
+       }
+       for _, macho := range [][]byte{
+               {0xfe, 0xed, 0xfa, 0xcf},
+               {0xcf, 0xfa, 0xed, 0xfe},
+               {0xca, 0xfe, 0xba, 0xbe},
+               {0xbe, 0xba, 0xfe, 0xca},
+       } {
+               if bytes.Equal(magic, macho) {
+                       return true
+               }
+       }
+       return false
+}
+
+func scanCodexDarwinApps(homes []homeDir) []Installation {
+       if runtime.GOOS != "darwin" {
+               return nil
+       }
+       apps := []struct {
+               bundle     string
+               executable string
+       }{
+               {bundle: "ChatGPT.app", executable: "ChatGPT"},
+               {bundle: "Codex.app", executable: "Codex"},
+       }
+
+       var result []Installation
+       for _, app := range apps {
+               systemExecutable := filepath.Join("/Applications", app.bundle, 
"Contents", "MacOS", app.executable)
+               if info, err := os.Stat(systemExecutable); err == nil && 
info.Mode().IsRegular() {
+                       result = append(result, Installation{
+                               AgentId: "codex", Name: "ChatGPT Desktop 
(Codex)", Path: systemExecutable,
+                               InstallMethod: "app", Owner: 
fileOwner(systemExecutable),
+                       })
+               }
+               for _, home := range homes {
+                       executable := filepath.Join(home.path, "Applications", 
app.bundle, "Contents", "MacOS", app.executable)
+                       if info, err := os.Stat(executable); err != nil || 
!info.Mode().IsRegular() {
+                               continue
+                       }
+                       result = append(result, Installation{
+                               AgentId: "codex", Name: "ChatGPT Desktop 
(Codex)", Path: executable,
+                               InstallMethod: "app", Owner: home.owner,
+                       })
+               }
+       }
+       return result
+}
+
+func expandSharedCodexInstallations(installations []Installation, homes 
[]homeDir) []Installation {
+       var result []Installation
+       for _, installation := range installations {
+               clean := filepath.Clean(installation.Path)
+               if installation.AgentId == "codex-cli" && 
installation.InstallMethod == "native" &&
+                       !isCodexNativeBinary(clean) {
+                       continue
+               }
+               shared := installation.AgentId == "codex" && 
strings.HasPrefix(clean, "/Applications/") ||
+                       installation.AgentId == "codex-cli" &&
+                               (strings.HasPrefix(clean, "/usr/") ||
+                                       strings.HasPrefix(clean, "/opt/") ||
+                                       strings.HasPrefix(clean, 
"/home/linuxbrew/"))
+               if !shared || len(homes) == 0 {
+                       result = append(result, installation)
+                       continue
+               }
+               for _, home := range homes {
+                       if home.path != "/root" && home.path != "/var/root" &&
+                               !strings.HasPrefix(home.path, "/home/") &&
+                               !strings.HasPrefix(home.path, "/Users/") {
+                               continue
+                       }
+                       expanded := installation
+                       expanded.Owner = home.owner
+                       result = append(result, expanded)
+               }
+       }
+       return result
+}
diff --git a/agent/codex_windows_shared.go b/agent/codex_windows_shared.go
new file mode 100644
index 0000000..d39f82b
--- /dev/null
+++ b/agent/codex_windows_shared.go
@@ -0,0 +1,43 @@
+// Copyright 2025 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package agent
+
+import "strings"
+
+func expandSharedCodexWindowsInstallations(installations []Installation, homes 
[]homeDir) []Installation {
+       var result []Installation
+       for _, installation := range installations {
+               if (installation.AgentId != "codex" && installation.AgentId != 
"codex-cli") ||
+                       !strings.EqualFold(installation.Owner, "SYSTEM") {
+                       result = append(result, installation)
+                       continue
+               }
+               matchedProfile := false
+               for _, home := range homes {
+                       normalized := 
strings.ToLower(strings.ReplaceAll(home.path, `/`, `\`))
+                       if !strings.Contains(normalized, `\users\`) {
+                               continue
+                       }
+                       row := installation
+                       row.Owner = home.owner
+                       result = append(result, row)
+                       matchedProfile = true
+               }
+               if !matchedProfile {
+                       result = append(result, installation)
+               }
+       }
+       return result
+}
diff --git a/agent/codex_windows_shared_test.go 
b/agent/codex_windows_shared_test.go
new file mode 100644
index 0000000..59d2232
--- /dev/null
+++ b/agent/codex_windows_shared_test.go
@@ -0,0 +1,56 @@
+// Copyright 2025 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package agent
+
+import "testing"
+
+func TestExpandSharedCodexWindowsInstallations(t *testing.T) {
+       installation := Installation{AgentId: "codex", Path: `C:\Program 
Files\WindowsApps\Codex.exe`, Owner: "SYSTEM"}
+       tests := []struct {
+               name  string
+               homes []homeDir
+               want  []string
+       }{
+               {name: "no profiles", want: []string{"SYSTEM"}},
+               {
+                       name:  "no user profiles",
+                       homes: []homeDir{{owner: "SYSTEM", path: 
`C:\Windows\System32\config\systemprofile`}},
+                       want:  []string{"SYSTEM"},
+               },
+               {
+                       name: "user profiles",
+                       homes: []homeDir{
+                               {owner: "SYSTEM", path: 
`C:\Windows\System32\config\systemprofile`},
+                               {owner: "alice", path: `C:\Users\alice`},
+                               {owner: "bob", path: `D:\Users\bob`},
+                       },
+                       want: []string{"alice", "bob"},
+               },
+       }
+
+       for _, test := range tests {
+               t.Run(test.name, func(t *testing.T) {
+                       got := 
expandSharedCodexWindowsInstallations([]Installation{installation}, test.homes)
+                       if len(got) != len(test.want) {
+                               t.Fatalf("got %d installations, want %d", 
len(got), len(test.want))
+                       }
+                       for i, owner := range test.want {
+                               if got[i].Owner != owner {
+                                       t.Errorf("installation %d owner = %q, 
want %q", i, got[i].Owner, owner)
+                               }
+                       }
+               })
+       }
+}
diff --git a/agent/fingerprint.go b/agent/fingerprint.go
new file mode 100644
index 0000000..0735ce3
--- /dev/null
+++ b/agent/fingerprint.go
@@ -0,0 +1,40 @@
+// Copyright 2025 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package agent
+
+import "github.com/apache/casbin-gateway/localserver"
+
+// Fingerprint describes known installation layouts for one agent.
+type Fingerprint struct {
+       ID          string `json:"id"`
+       DisplayName string `json:"displayName"`
+
+       ExecName            string              `json:"execName,omitempty"`
+       StateDir            string              `json:"stateDir,omitempty"`
+       NpmPackage          string              `json:"npmPackage,omitempty"`
+       ExtraUnixNpmDirs    []string            
`json:"extraUnixNpmDirs,omitempty"`
+       ExtraWindowsNpmDirs []string            
`json:"extraWindowsNpmDirs,omitempty"`
+       WingetPackage       string              `json:"wingetPackage,omitempty"`
+       MSIXFamily          string              `json:"msixFamily,omitempty"`
+       DesktopInstallerDir string              
`json:"desktopInstallerDir,omitempty"`
+       WindowsProgramDirs  []string            
`json:"windowsProgramDirs,omitempty"`
+       WindowsUserDirs     []string            
`json:"windowsUserDirs,omitempty"`
+       HomebrewCasks       []string            `json:"homebrewCasks,omitempty"`
+       SystemPackage       string              `json:"systemPackage,omitempty"`
+       BuildInfoModule     string              
`json:"buildInfoModule,omitempty"`
+       BuildInfoVersionVar string              
`json:"buildInfoVersionVar,omitempty"`
+       VersionFile         string              `json:"versionFile,omitempty"`
+       LocalServer         *localserver.Server `json:"localServer,omitempty"`
+}
diff --git a/agent/fingerprint_load.go b/agent/fingerprint_load.go
new file mode 100644
index 0000000..f4c8543
--- /dev/null
+++ b/agent/fingerprint_load.go
@@ -0,0 +1,146 @@
+// Copyright 2025 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package agent
+
+import (
+       "bytes"
+       "embed"
+       "encoding/json"
+       "errors"
+       "fmt"
+       "io/fs"
+       pathpkg "path"
+       "sort"
+       "strings"
+
+       "github.com/apache/casbin-gateway/localserver"
+)
+
+// Fingerprints are embedded in the Gateway binary.
+//
+//go:embed fingerprints/*.json
+var fingerprintFS embed.FS
+
+const fingerprintDir = "fingerprints"
+
+var fingerprints = mustLoadFingerprints(fingerprintFS)
+
+// mustLoadFingerprints rejects invalid embedded build data at startup.
+func mustLoadFingerprints(files fs.FS) []Fingerprint {
+       loaded, err := loadFingerprints(files)
+       if err != nil {
+               panic("agent: cannot load agent fingerprints: " + err.Error())
+       }
+       return loaded
+}
+
+// loadFingerprints loads and validates fingerprint files in stable order.
+func loadFingerprints(files fs.FS) ([]Fingerprint, error) {
+       names, err := fs.Glob(files, fingerprintDir+"/*.json")
+       if err != nil {
+               return nil, err
+       }
+       sort.Strings(names)
+       if len(names) == 0 {
+               return nil, fmt.Errorf("no fingerprint files under %s/", 
fingerprintDir)
+       }
+
+       loaded := make([]Fingerprint, 0, len(names))
+       seen := make(map[string]bool, len(names))
+       for _, name := range names {
+               fingerprint, err := decodeFingerprint(files, name)
+               if err != nil {
+                       return nil, err
+               }
+               if seen[fingerprint.ID] {
+                       return nil, fmt.Errorf("%s: duplicate agent id %q", 
name, fingerprint.ID)
+               }
+               seen[fingerprint.ID] = true
+               loaded = append(loaded, fingerprint)
+       }
+       return loaded, nil
+}
+
+// decodeFingerprint rejects unknown JSON fields.
+func decodeFingerprint(files fs.FS, name string) (Fingerprint, error) {
+       data, err := fs.ReadFile(files, name)
+       if err != nil {
+               return Fingerprint{}, err
+       }
+
+       decoder := json.NewDecoder(bytes.NewReader(data))
+       decoder.DisallowUnknownFields()
+       var fingerprint Fingerprint
+       if err := decoder.Decode(&fingerprint); err != nil {
+               return Fingerprint{}, fmt.Errorf("%s: %w", name, err)
+       }
+       if decoder.More() {
+               return Fingerprint{}, fmt.Errorf("%s: trailing content after 
the fingerprint object", name)
+       }
+       if err := validateFingerprint(fingerprint, name); err != nil {
+               return Fingerprint{}, fmt.Errorf("%s: %w", name, err)
+       }
+       return fingerprint, nil
+}
+
+// validateFingerprint requires a usable detection source.
+func validateFingerprint(f Fingerprint, name string) error {
+       if id := strings.TrimSuffix(pathpkg.Base(name), ".json"); f.ID != id {
+               return fmt.Errorf("id %q does not match the file name %q", 
f.ID, id)
+       }
+       if f.DisplayName == "" {
+               return errors.New("displayName is required")
+       }
+
+       if err := validateLocalServer(f.LocalServer); err != nil {
+               return err
+       }
+       if !hasScanSource(f) {
+               return errors.New("no supported installation layout or local 
server")
+       }
+       return nil
+}
+
+func hasScanSource(f Fingerprint) bool {
+       if f.LocalServer != nil {
+               return true
+       }
+       if f.ExecName == "" {
+               return false
+       }
+       return f.StateDir != "" || f.NpmPackage != "" || f.WingetPackage != "" 
||
+               f.MSIXFamily != "" || f.DesktopInstallerDir != "" ||
+               len(f.WindowsProgramDirs) != 0 || len(f.WindowsUserDirs) != 0 ||
+               len(f.HomebrewCasks) != 0 || f.SystemPackage != ""
+}
+
+// validateLocalServer rejects incomplete probes.
+func validateLocalServer(server *localserver.Server) error {
+       if server == nil {
+               return nil
+       }
+       if len(server.Ports) == 0 {
+               return errors.New("localServer.ports is required")
+       }
+       if server.ProbePath == "" || len(server.ProbeMarkers) == 0 {
+               return errors.New("localServer needs both probePath and 
probeMarkers to confirm anything")
+       }
+       for i, marker := range server.ProbeMarkers {
+               if strings.TrimSpace(marker) == "" {
+                       return fmt.Errorf("localServer.probeMarkers[%d] is 
empty, which confirms nothing", i)
+               }
+       }
+       return nil
+}
diff --git a/agent/fingerprint_load_test.go b/agent/fingerprint_load_test.go
new file mode 100644
index 0000000..14fca08
--- /dev/null
+++ b/agent/fingerprint_load_test.go
@@ -0,0 +1,125 @@
+// Copyright 2025 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package agent
+
+import (
+       "strings"
+       "testing"
+       "testing/fstest"
+)
+
+func TestFingerprintsLoad(t *testing.T) {
+       want := []string{
+               "claude-code", "claude-desktop", "codex-cli", "codex", 
"cursor-agent",
+               "cursor", "openagent", "openclaw", "windsurf",
+       }
+
+       loaded, err := loadFingerprints(fingerprintFS)
+       if err != nil {
+               t.Fatalf("loadFingerprints() failed: %v", err)
+       }
+       if len(loaded) != len(want) {
+               t.Fatalf("loaded %d fingerprints, want %d", len(loaded), 
len(want))
+       }
+       for i, id := range want {
+               if loaded[i].ID != id {
+                       t.Errorf("fingerprint %d is %q, want %q", i, 
loaded[i].ID, id)
+               }
+       }
+}
+
+func TestLoadFingerprintsRejects(t *testing.T) {
+       tests := []struct {
+               name    string
+               file    string
+               content string
+               want    string
+       }{{
+               name:    "malformed json",
+               file:    "fingerprints/broken.json",
+               content: `{"id": "broken",`,
+               want:    "unexpected EOF",
+       }, {
+               name:    "misspelled field",
+               file:    "fingerprints/typo.json",
+               content: `{"id": "typo", "displayName": "Typo", "execName": 
"typo", "stateDir": "typo", "execNam": "typo"}`,
+               want:    "unknown field",
+       }, {
+               name:    "legacy cmdline field",
+               file:    "fingerprints/legacy-cmd.json",
+               content: `{"id": "legacy-cmd", "displayName": "Legacy", 
"cmdMarkers": ["legacy"]}`,
+               want:    "unknown field",
+       }, {
+               name:    "legacy executable rule field",
+               file:    "fingerprints/legacy-rule.json",
+               content: `{"id": "legacy-rule", "displayName": "Legacy", 
"extraExecRules": [{"suffix": "/legacy"}]}`,
+               want:    "unknown field",
+       }, {
+               name:    "legacy notes field",
+               file:    "fingerprints/legacy-notes.json",
+               content: `{"id": "legacy-notes", "displayName": "Legacy", 
"notes": ["unused"]}`,
+               want:    "unknown field",
+       }, {
+               name:    "id does not match file name",
+               file:    "fingerprints/renamed.json",
+               content: `{"id": "other", "displayName": "Other", "execName": 
"other", "stateDir": "other"}`,
+               want:    "does not match the file name",
+       }, {
+               name:    "missing display name",
+               file:    "fingerprints/nameless.json",
+               content: `{"id": "nameless", "execName": "nameless", 
"stateDir": "nameless"}`,
+               want:    "displayName is required",
+       }, {
+               name:    "no evidence at all",
+               file:    "fingerprints/ghost.json",
+               content: `{"id": "ghost", "displayName": "Ghost", "execName": 
"ghost"}`,
+               want:    "no supported installation layout",
+       }, {
+               name:    "incomplete local server",
+               file:    "fingerprints/server.json",
+               content: `{"id": "server", "displayName": "Server", 
"localServer": {"ports": [1234]}}`,
+               want:    "probePath and probeMarkers",
+       }, {
+               name:    "local server without ports",
+               file:    "fingerprints/portless.json",
+               content: `{"id": "portless", "displayName": "Portless", 
"localServer": {"probePath": "/health", "probeMarkers": ["x"]}}`,
+               want:    "localServer.ports is required",
+       }, {
+               name:    "local server with empty marker",
+               file:    "fingerprints/empty-marker.json",
+               content: `{"id": "empty-marker", "displayName": "Empty Marker", 
"localServer": {"ports": [1234], "probePath": "/health", "probeMarkers": 
[""]}}`,
+               want:    "confirms nothing",
+       }}
+
+       for _, test := range tests {
+               t.Run(test.name, func(t *testing.T) {
+                       files := fstest.MapFS{test.file: &fstest.MapFile{Data: 
[]byte(test.content)}}
+                       _, err := loadFingerprints(files)
+                       if err == nil {
+                               t.Fatalf("loadFingerprints() accepted %s", 
test.name)
+                       }
+                       if !strings.Contains(err.Error(), test.want) {
+                               t.Errorf("loadFingerprints() error = %q, want 
it to mention %q", err, test.want)
+                       }
+               })
+       }
+}
+
+func TestLoadFingerprintsRejectsEmptyRegistry(t *testing.T) {
+       _, err := loadFingerprints(fstest.MapFS{"other/claude-code.json": 
&fstest.MapFile{Data: []byte("{}")}})
+       if err == nil || !strings.Contains(err.Error(), "no fingerprint files") 
{
+               t.Errorf("loadFingerprints() on an empty registry = %v, want a 
no-fingerprint-files error", err)
+       }
+}
diff --git a/agent/fingerprints/claude-code.json 
b/agent/fingerprints/claude-code.json
new file mode 100644
index 0000000..d488603
--- /dev/null
+++ b/agent/fingerprints/claude-code.json
@@ -0,0 +1,10 @@
+{
+  "id": "claude-code",
+  "displayName": "Claude Code",
+  "execName": "claude",
+  "stateDir": "claude",
+  "npmPackage": "@anthropic-ai/claude-code",
+  "wingetPackage": "Anthropic.ClaudeCode",
+  "homebrewCasks": ["claude-code", "claude-code@latest"],
+  "systemPackage": "claude-code"
+}
diff --git a/agent/fingerprints/claude-desktop.json 
b/agent/fingerprints/claude-desktop.json
new file mode 100644
index 0000000..5f95584
--- /dev/null
+++ b/agent/fingerprints/claude-desktop.json
@@ -0,0 +1,7 @@
+{
+  "id": "claude-desktop",
+  "displayName": "Claude Desktop",
+  "execName": "claude",
+  "msixFamily": "Claude_pzs8sxrjxfjjc",
+  "desktopInstallerDir": "AnthropicClaude"
+}
diff --git a/agent/fingerprints/codex-cli.json 
b/agent/fingerprints/codex-cli.json
new file mode 100644
index 0000000..c8c523e
--- /dev/null
+++ b/agent/fingerprints/codex-cli.json
@@ -0,0 +1,9 @@
+{
+  "id": "codex-cli",
+  "displayName": "Codex CLI",
+  "execName": "codex",
+  "stateDir": "codex",
+  "npmPackage": "@openai/codex",
+  "homebrewCasks": ["codex", "codex@latest"],
+  "windowsUserDirs": ["OpenAI/Codex/bin"]
+}
diff --git a/agent/fingerprints/codex.json b/agent/fingerprints/codex.json
new file mode 100644
index 0000000..6dc0e6e
--- /dev/null
+++ b/agent/fingerprints/codex.json
@@ -0,0 +1,6 @@
+{
+  "id": "codex",
+  "displayName": "ChatGPT Desktop (Codex)",
+  "execName": "ChatGPT",
+  "msixFamily": "OpenAI.Codex_2p2nqsd0c76g0"
+}
diff --git a/agent/fingerprints/cursor-agent.json 
b/agent/fingerprints/cursor-agent.json
new file mode 100644
index 0000000..779e53c
--- /dev/null
+++ b/agent/fingerprints/cursor-agent.json
@@ -0,0 +1,7 @@
+{
+  "id": "cursor-agent",
+  "displayName": "Cursor Agent",
+  "execName": "cursor-agent",
+  "stateDir": "cursor-agent",
+  "windowsProgramDirs": ["cursor-agent"]
+}
diff --git a/agent/fingerprints/cursor.json b/agent/fingerprints/cursor.json
new file mode 100644
index 0000000..6f4bbad
--- /dev/null
+++ b/agent/fingerprints/cursor.json
@@ -0,0 +1,7 @@
+{
+  "id": "cursor",
+  "displayName": "Cursor",
+  "execName": "Cursor",
+  "windowsProgramDirs": ["cursor"],
+  "homebrewCasks": ["cursor"]
+}
diff --git a/agent/fingerprints/openagent.json 
b/agent/fingerprints/openagent.json
new file mode 100644
index 0000000..ac4b31b
--- /dev/null
+++ b/agent/fingerprints/openagent.json
@@ -0,0 +1,17 @@
+{
+  "id": "openagent",
+  "displayName": "OpenAgent",
+  "execName": "openagent",
+  "stateDir": "openagent",
+  "windowsUserDirs": ["openagent"],
+  "buildInfoModule": "github.com/the-open-agent/openagent",
+  "buildInfoVersionVar": 
"github.com/the-open-agent/openagent/internal/cli.Version",
+  "versionFile": "version",
+  "localServer": {
+    "ports": [14000],
+    "probePath": "/api/health",
+    "probeMarkers": ["openagent_session_id"],
+    "versionPath": "/api/get-version-info",
+    "versionFields": ["data", "version"]
+  }
+}
diff --git a/agent/fingerprints/openclaw.json b/agent/fingerprints/openclaw.json
new file mode 100644
index 0000000..1c60c1f
--- /dev/null
+++ b/agent/fingerprints/openclaw.json
@@ -0,0 +1,8 @@
+{
+  "id": "openclaw",
+  "displayName": "OpenClaw",
+  "execName": "openclaw",
+  "npmPackage": "openclaw",
+  "extraUnixNpmDirs": [".openclaw/tools/node-v*/lib"],
+  "extraWindowsNpmDirs": ["OpenClaw/deps/portable-node"]
+}
diff --git a/agent/fingerprints/windsurf.json b/agent/fingerprints/windsurf.json
new file mode 100644
index 0000000..c18800f
--- /dev/null
+++ b/agent/fingerprints/windsurf.json
@@ -0,0 +1,7 @@
+{
+  "id": "windsurf",
+  "displayName": "Windsurf",
+  "execName": "Windsurf",
+  "windowsProgramDirs": ["Windsurf"],
+  "homebrewCasks": ["windsurf"]
+}
diff --git a/agent/scan.go b/agent/scan.go
new file mode 100644
index 0000000..944ba6a
--- /dev/null
+++ b/agent/scan.go
@@ -0,0 +1,102 @@
+// Copyright 2025 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package agent
+
+import (
+       "context"
+       "fmt"
+       "sync"
+       "time"
+)
+
+const (
+       scanCacheTTL = 30 * time.Second
+       scanTimeout  = 10 * time.Second
+)
+
+type scanCall struct {
+       done   chan struct{}
+       result []Installation
+       err    error
+}
+
+var installationCache = struct {
+       sync.Mutex
+       result    []Installation
+       updatedAt time.Time
+       current   *scanCall
+}{}
+
+// Scan returns installations visible to Gateway and caches successful results.
+func Scan(forceRefresh bool) ([]Installation, error) {
+       installationCache.Lock()
+       if !forceRefresh && time.Since(installationCache.updatedAt) < 
scanCacheTTL {
+               result := cloneInstallations(installationCache.result)
+               installationCache.Unlock()
+               return result, nil
+       }
+       if installationCache.current != nil {
+               current := installationCache.current
+               installationCache.Unlock()
+               <-current.done
+               return cloneInstallations(current.result), current.err
+       }
+
+       current := &scanCall{done: make(chan struct{})}
+       installationCache.current = current
+       installationCache.Unlock()
+
+       current.result, current.err = scanWithTimeout()
+
+       installationCache.Lock()
+       if current.err == nil {
+               installationCache.result = cloneInstallations(current.result)
+               installationCache.updatedAt = time.Now()
+       }
+       installationCache.current = nil
+       close(current.done)
+       installationCache.Unlock()
+
+       return cloneInstallations(current.result), current.err
+}
+
+func scanWithTimeout() ([]Installation, error) {
+       ctx, cancel := context.WithTimeout(context.Background(), scanTimeout)
+       defer cancel()
+
+       result := make(chan []Installation, 1)
+       go func() {
+               result <- scan(ctx)
+       }()
+
+       select {
+       case installations := <-result:
+               if err := ctx.Err(); err != nil {
+                       return nil, fmt.Errorf("agent scan timed out after %s: 
%w", scanTimeout, err)
+               }
+               return installations, nil
+       case <-ctx.Done():
+               return nil, fmt.Errorf("agent scan timed out after %s: %w", 
scanTimeout, ctx.Err())
+       }
+}
+
+func cloneInstallations(installations []Installation) []Installation {
+       if installations == nil {
+               return nil
+       }
+       result := make([]Installation, len(installations))
+       copy(result, installations)
+       return result
+}
diff --git a/agent/scan_common.go b/agent/scan_common.go
new file mode 100644
index 0000000..aa72c53
--- /dev/null
+++ b/agent/scan_common.go
@@ -0,0 +1,188 @@
+// Copyright 2025 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package agent
+
+import (
+       "context"
+       "encoding/json"
+       "os"
+       "os/exec"
+       "os/user"
+       "path/filepath"
+       "runtime"
+       "sort"
+       "strings"
+
+       "github.com/apache/casbin-gateway/internal/hermes"
+)
+
+// maxPackageJSONSize limits reads from glob-matched manifests.
+const maxPackageJSONSize = 1024 * 1024
+
+// scanNpmPatterns validates npm packages found by known glob patterns.
+func scanNpmPatterns(ctx context.Context, fingerprint *Fingerprint, patterns 
[]string, owner string, ownerFor func(string) string) []Installation {
+       if fingerprint.NpmPackage == "" {
+               return nil
+       }
+
+       var result []Installation
+       for _, pattern := range patterns {
+               if ctx.Err() != nil {
+                       return result
+               }
+               matches, _ := filepath.Glob(pattern)
+               for _, packageJSON := range matches {
+                       if ctx.Err() != nil {
+                               return result
+                       }
+                       info, err := os.Stat(packageJSON)
+                       if err != nil || !info.Mode().IsRegular() || 
info.Size() > maxPackageJSONSize {
+                               continue
+                       }
+                       data, err := os.ReadFile(packageJSON)
+                       if err != nil {
+                               continue
+                       }
+                       var pkg struct {
+                               Name    string `json:"name"`
+                               Version string `json:"version"`
+                       }
+                       if json.Unmarshal(data, &pkg) != nil || pkg.Name != 
fingerprint.NpmPackage || pkg.Version == "" {
+                               continue
+                       }
+                       packageOwner := owner
+                       if packageOwner == "" && ownerFor != nil {
+                               packageOwner = ownerFor(packageJSON)
+                       }
+                       result = append(result, Installation{
+                               Name: fingerprint.DisplayName, Version: 
pkg.Version, Path: filepath.Dir(packageJSON),
+                               InstallMethod: "npm", Owner: packageOwner,
+                       })
+               }
+       }
+       return result
+}
+
+func (f *Fingerprint) npmPackagePath() string {
+       return filepath.FromSlash(f.NpmPackage)
+}
+
+func stampAgentId(installations []Installation, mark int, agentId string) {
+       for i := mark; i < len(installations); i++ {
+               installations[i].AgentId = agentId
+       }
+}
+
+// dedupeInstallations returns unique installations ordered by owner and path.
+func dedupeInstallations(installations []Installation) []Installation {
+       seen := map[string]bool{}
+       result := make([]Installation, 0, len(installations))
+       for _, installation := range installations {
+               key := canonicalPath(installation.Path)
+               if key == "" {
+                       continue
+               }
+               if runtime.GOOS == "windows" {
+                       key = strings.ToLower(key)
+               }
+               if seen[key] {
+                       continue
+               }
+               seen[key] = true
+               result = append(result, installation)
+       }
+       sort.Slice(result, func(i, j int) bool {
+               if result[i].Owner != result[j].Owner {
+                       return result[i].Owner < result[j].Owner
+               }
+               return result[i].Path < result[j].Path
+       })
+       return result
+}
+
+func canonicalPath(path string) string {
+       if resolved, err := filepath.EvalSymlinks(path); err == nil {
+               return resolved
+       }
+       return filepath.Clean(path)
+}
+
+// versionUnderDir extracts a version directory below root.
+func versionUnderDir(root, target string) string {
+       relative, err := filepath.Rel(root, target)
+       if err != nil || relative == "." || relative == ".." || 
strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
+               return ""
+       }
+       return strings.Split(relative, string(filepath.Separator))[0]
+}
+
+func findExecutablePath(files, execName string) string {
+       for _, line := range strings.Split(files, "\n") {
+               line = strings.TrimSpace(line)
+               if filepath.Base(line) == execName {
+                       return line
+               }
+       }
+       return ""
+}
+
+func hermesInstallation(launcher, owner string, officialRoots ...string) 
[]Installation {
+       project, err := hermes.InspectLauncher(launcher, officialRoots...)
+       if err != nil {
+               return nil
+       }
+       method := "source"
+       for _, root := range officialRoots {
+               if sameHermesPath(root, project.Root) {
+                       method = "native"
+                       break
+               }
+       }
+       return []Installation{{
+               AgentId: hermes.AgentID, Name: hermes.DisplayName, Version: 
project.Version,
+               Path: launcher, InstallMethod: method, Owner: owner,
+       }}
+}
+
+func scanHermesOnPath() []Installation {
+       launcher, err := exec.LookPath(hermes.ExecName)
+       if err != nil && runtime.GOOS == "windows" {
+               launcher, err = exec.LookPath(hermes.ExecName + ".exe")
+       }
+       if err != nil {
+               return nil
+       }
+       owner := ""
+       if account, userErr := user.Current(); userErr == nil {
+               owner = account.Username
+       }
+       return hermesInstallation(launcher, owner)
+}
+
+func sameHermesPath(left, right string) bool {
+       leftResolved, leftErr := filepath.EvalSymlinks(left)
+       rightResolved, rightErr := filepath.EvalSymlinks(right)
+       if leftErr == nil {
+               left = leftResolved
+       }
+       if rightErr == nil {
+               right = rightResolved
+       }
+       left, right = filepath.Clean(left), filepath.Clean(right)
+       if runtime.GOOS == "windows" {
+               return strings.EqualFold(left, right)
+       }
+       return left == right
+}
diff --git a/agent/scan_darwin.go b/agent/scan_darwin.go
new file mode 100644
index 0000000..1784344
--- /dev/null
+++ b/agent/scan_darwin.go
@@ -0,0 +1,130 @@
+// Copyright 2025 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//go:build darwin
+
+package agent
+
+import (
+       "context"
+       "os"
+       "os/user"
+       "path/filepath"
+
+       "github.com/apache/casbin-gateway/internal/hermes"
+)
+
+// scan finds installations of every known agent in known macOS layouts. It
+// neither executes discovered binaries nor walks arbitrary filesystem roots.
+func scan(ctx context.Context) []Installation {
+       homes := darwinHomes()
+
+       var installations []Installation
+       for i := range fingerprints {
+               fingerprint := &fingerprints[i]
+               if ctx.Err() != nil {
+                       return installations
+               }
+               mark := len(installations)
+               for _, home := range homes {
+                       if ctx.Err() != nil {
+                               return installations
+                       }
+                       installations = append(installations, 
scanNative(fingerprint, home)...)
+                       installations = append(installations, 
scanNpmPatterns(ctx, fingerprint, userNpmPatterns(fingerprint, home.path), 
home.owner, fileOwner)...)
+               }
+               for _, prefix := range []string{"/opt/homebrew", "/usr/local"} {
+                       installations = append(installations, 
scanDarwinHomebrew(fingerprint, prefix)...)
+                       installations = append(installations, 
scanDarwinSystemNpm(ctx, fingerprint, prefix)...)
+               }
+               stampAgentId(installations, mark, fingerprint.ID)
+               fillMissingVersions(installations, mark, fingerprint)
+       }
+       for _, home := range homes {
+               if ctx.Err() != nil {
+                       return installations
+               }
+               installations = append(installations, scanHermesUnix(home, 
filepath.Join(home.path, ".local", "bin", hermes.ExecName))...)
+       }
+       installations = append(installations, scanHermesOnPath()...)
+       installations = append(installations, scanCodexStandalone()...)
+       installations = append(installations, scanCodexDarwinApps(homes)...)
+       // Last, so that an agent found both on disk and by its port keeps the
+       // richer install-layout row when the two resolve to the same 
executable.
+       installations = append(installations, scanLocalServers(ctx)...)
+       return 
expandSharedCodexInstallations(dedupeInstallations(installations), homes)
+}
+
+func darwinHomes() []homeDir {
+       seen := map[string]bool{}
+       var homes []homeDir
+       add := func(owner, path string) {
+               path = filepath.Clean(path)
+               if info, err := os.Stat(path); err != nil || !info.IsDir() || 
seen[path] {
+                       return
+               }
+               seen[path] = true
+               if owner == "" {
+                       owner = filepath.Base(path)
+               }
+               homes = append(homes, homeDir{owner: owner, path: path})
+       }
+
+       if entries, err := os.ReadDir("/Users"); err == nil {
+               for _, entry := range entries {
+                       if entry.Name() != "Shared" {
+                               add(entry.Name(), filepath.Join("/Users", 
entry.Name()))
+                       }
+               }
+       }
+       add("root", "/var/root")
+       if home, err := os.UserHomeDir(); err == nil {
+               owner := ""
+               if account, err := user.Current(); err == nil {
+                       owner = account.Username
+               }
+               add(owner, home)
+       }
+       return homes
+}
+
+// scanDarwinHomebrew reads the Caskroom layout directly; on macOS every cask
+// version keeps its own directory, so no brew invocation is needed.
+func scanDarwinHomebrew(fingerprint *Fingerprint, prefix string) 
[]Installation {
+       if fingerprint.ExecName == "" {
+               return nil
+       }
+
+       var result []Installation
+       for _, cask := range fingerprint.HomebrewCasks {
+               versions, _ := filepath.Glob(filepath.Join(prefix, "Caskroom", 
cask, "*"))
+               for _, versionDir := range versions {
+                       executable := filepath.Join(versionDir, 
fingerprint.ExecName)
+                       info, err := os.Stat(executable)
+                       if err != nil || !info.Mode().IsRegular() || 
info.Mode()&0o111 == 0 {
+                               continue
+                       }
+                       result = append(result, Installation{
+                               Name: fingerprint.DisplayName, Version: 
filepath.Base(versionDir), Path: executable,
+                               InstallMethod: "homebrew", Owner: 
fileOwner(executable),
+                       })
+               }
+       }
+       return result
+}
+
+func scanDarwinSystemNpm(ctx context.Context, fingerprint *Fingerprint, prefix 
string) []Installation {
+       pattern := filepath.Join(prefix, "lib", "node_modules", 
fingerprint.npmPackagePath(), "package.json")
+       return scanNpmPatterns(ctx, fingerprint, []string{pattern}, "", 
fileOwner)
+}
diff --git a/agent/scan_linux.go b/agent/scan_linux.go
new file mode 100644
index 0000000..26b9921
--- /dev/null
+++ b/agent/scan_linux.go
@@ -0,0 +1,216 @@
+// Copyright 2025 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//go:build linux
+
+package agent
+
+import (
+       "context"
+       "os"
+       "os/exec"
+       "path/filepath"
+       "strings"
+       "time"
+
+       "github.com/apache/casbin-gateway/internal/hermes"
+)
+
+// scan finds installations of every known agent without executing any
+// discovered binary or traversing arbitrary filesystem roots.
+func scan(ctx context.Context) []Installation {
+       homes := readHomes("/etc/passwd")
+
+       var installations []Installation
+       for i := range fingerprints {
+               fingerprint := &fingerprints[i]
+               if ctx.Err() != nil {
+                       return installations
+               }
+               mark := len(installations)
+               for _, home := range homes {
+                       if ctx.Err() != nil {
+                               return installations
+                       }
+                       installations = append(installations, 
scanNative(fingerprint, home)...)
+                       installations = append(installations, 
scanNpmPatterns(ctx, fingerprint, userNpmPatterns(fingerprint, home.path), 
home.owner, fileOwner)...)
+               }
+               installations = append(installations, scanSystemNpm(ctx, 
fingerprint)...)
+               installations = append(installations, scanSystemPackages(ctx, 
fingerprint)...)
+               installations = append(installations, scanHomebrew(ctx, 
fingerprint)...)
+               stampAgentId(installations, mark, fingerprint.ID)
+               fillMissingVersions(installations, mark, fingerprint)
+       }
+       for _, home := range homes {
+               if ctx.Err() != nil {
+                       return installations
+               }
+               installations = append(installations, scanHermesUnix(home, 
filepath.Join(home.path, ".local", "bin", hermes.ExecName))...)
+       }
+       installations = append(installations, scanHermesUnix(
+               homeDir{owner: "root", path: "/root"},
+               filepath.Join("/usr/local/bin", hermes.ExecName),
+               filepath.Join("/usr/local/lib", hermes.ProjectDir),
+               filepath.Join("/root/.hermes", hermes.ProjectDir),
+       )...)
+       installations = append(installations, scanHermesOnPath()...)
+       installations = append(installations, scanCodexStandalone()...)
+       // Last, so that an agent found both on disk and by its port keeps the
+       // richer install-layout row when the two resolve to the same 
executable.
+       installations = append(installations, scanLocalServers(ctx)...)
+       return 
expandSharedCodexInstallations(dedupeInstallations(installations), homes)
+}
+
+func readHomes(passwdPath string) []homeDir {
+       data, err := os.ReadFile(passwdPath)
+       if err != nil {
+               return nil
+       }
+
+       seen := map[string]bool{}
+       var homes []homeDir
+       for _, line := range strings.Split(string(data), "\n") {
+               fields := strings.Split(line, ":")
+               if len(fields) < 6 || !filepath.IsAbs(fields[5]) || 
seen[fields[5]] {
+                       continue
+               }
+               info, err := os.Stat(fields[5])
+               if err != nil || !info.IsDir() {
+                       continue
+               }
+               seen[fields[5]] = true
+               homes = append(homes, homeDir{owner: fields[0], path: 
fields[5]})
+       }
+       return homes
+}
+
+func scanSystemNpm(ctx context.Context, fingerprint *Fingerprint) 
[]Installation {
+       pkg := fingerprint.npmPackagePath()
+       return scanNpmPatterns(ctx, fingerprint, []string{
+               filepath.Join("/usr/local/lib/node_modules", pkg, 
"package.json"),
+               filepath.Join("/usr/lib/node_modules", pkg, "package.json"),
+       }, "", fileOwner)
+}
+
+// scanSystemPackages queries each distro package manager for the agent, which
+// reports both its version and the files it owns.
+func scanSystemPackages(ctx context.Context, fingerprint *Fingerprint) 
[]Installation {
+       pkg := fingerprint.SystemPackage
+       if pkg == "" {
+               return nil
+       }
+
+       var result []Installation
+       if version, ok := commandOutput(ctx, "dpkg-query", "-W", 
"-f=${Version}", pkg); ok {
+               files, _ := commandOutput(ctx, "dpkg", "-L", pkg)
+               result = append(result, packageInstallation(fingerprint, "apt", 
version, files))
+       }
+       if version, ok := commandOutput(ctx, "rpm", "-q", "--qf", 
"%{VERSION}-%{RELEASE}", pkg); ok {
+               files, _ := commandOutput(ctx, "rpm", "-ql", pkg)
+               result = append(result, packageInstallation(fingerprint, "rpm", 
version, files))
+       }
+       if installed, ok := commandOutput(ctx, "apk", "info", "-e", pkg); ok && 
strings.TrimSpace(installed) != "" {
+               version, _ := commandOutput(ctx, "apk", "info", "-v", pkg)
+               files, _ := commandOutput(ctx, "apk", "info", "-L", pkg)
+               version = strings.TrimPrefix(strings.TrimSpace(version), 
pkg+"-")
+               result = append(result, packageInstallation(fingerprint, "apk", 
version, files))
+       }
+       return result
+}
+
+func packageInstallation(fingerprint *Fingerprint, method, version, files 
string) Installation {
+       path := findExecutablePath(files, fingerprint.ExecName)
+       if path == "" {
+               path = filepath.Join("/usr/bin", fingerprint.ExecName)
+       }
+       return Installation{
+               Name: fingerprint.DisplayName, Version: 
strings.TrimSpace(version), Path: path,
+               InstallMethod: method, Owner: "root",
+       }
+}
+
+func scanHomebrew(ctx context.Context, fingerprint *Fingerprint) 
[]Installation {
+       if len(fingerprint.HomebrewCasks) == 0 {
+               return nil
+       }
+
+       var result []Installation
+       for _, brew := range []string{"/home/linuxbrew/.linuxbrew/bin/brew", 
"/opt/homebrew/bin/brew", "/usr/local/bin/brew"} {
+               if ctx.Err() != nil {
+                       return result
+               }
+               if info, err := os.Stat(brew); err != nil || 
!info.Mode().IsRegular() {
+                       continue
+               }
+               owner := fileOwner(brew)
+               prefix := filepath.Dir(filepath.Dir(brew))
+               result = append(result, scanHomebrewCaskroom(fingerprint, 
prefix, owner)...)
+               for _, cask := range fingerprint.HomebrewCasks {
+                       out, ok := commandOutputPath(ctx, brew, "list", 
"--cask", "--versions", cask)
+                       if !ok || strings.TrimSpace(out) == "" {
+                               continue
+                       }
+                       fields := strings.Fields(out)
+                       version := ""
+                       if len(fields) > 1 {
+                               version = fields[len(fields)-1]
+                       }
+                       files, _ := commandOutputPath(ctx, brew, "list", 
"--cask", cask)
+                       path := findExecutablePath(files, fingerprint.ExecName)
+                       if path == "" {
+                               path = filepath.Join(filepath.Dir(brew), 
fingerprint.ExecName)
+                       }
+                       result = append(result, Installation{
+                               Name: fingerprint.DisplayName, Version: 
version, Path: path,
+                               InstallMethod: "homebrew", Owner: owner,
+                       })
+               }
+       }
+       return result
+}
+
+// scanHomebrewCaskroom reads the version straight from the Caskroom layout, so
+// a cask still resolves when the brew command itself is unavailable.
+func scanHomebrewCaskroom(fingerprint *Fingerprint, prefix, owner string) 
[]Installation {
+       launcher := filepath.Join(prefix, "bin", fingerprint.ExecName)
+       target, err := filepath.EvalSymlinks(launcher)
+       if err != nil {
+               return nil
+       }
+       for _, cask := range fingerprint.HomebrewCasks {
+               version := versionUnderDir(filepath.Join(prefix, "Caskroom", 
cask), target)
+               if version != "" {
+                       return []Installation{{
+                               Name: fingerprint.DisplayName, Version: 
version, Path: launcher,
+                               InstallMethod: "homebrew", Owner: owner,
+                       }}
+               }
+       }
+       return nil
+}
+
+func commandOutput(ctx context.Context, name string, args ...string) (string, 
bool) {
+       path, err := exec.LookPath(name)
+       if err != nil {
+               return "", false
+       }
+       return commandOutputPath(ctx, path, args...)
+}
+
+func commandOutputPath(ctx context.Context, path string, args ...string) 
(string, bool) {
+       ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
+       defer cancel()
+       out, err := exec.CommandContext(ctx, path, args...).Output()
+       return string(out), err == nil
+}
diff --git a/agent/scan_localserver.go b/agent/scan_localserver.go
new file mode 100644
index 0000000..0898c10
--- /dev/null
+++ b/agent/scan_localserver.go
@@ -0,0 +1,75 @@
+// Copyright 2025 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package agent
+
+import (
+       "context"
+       "os"
+       "strings"
+
+       "github.com/apache/casbin-gateway/localserver"
+)
+
+const localServerInstallMethod = "source"
+
+// scanLocalServers detects agents listening on configured loopback ports.
+func scanLocalServers(ctx context.Context) []Installation {
+       var installations []Installation
+       for i := range fingerprints {
+               fingerprint := &fingerprints[i]
+               if ctx.Err() != nil {
+                       return installations
+               }
+               if fingerprint.LocalServer == nil {
+                       continue
+               }
+               mark := len(installations)
+               for _, port := range fingerprint.LocalServer.Ports {
+                       installations = append(installations, 
scanLocalServerPort(ctx, fingerprint, port)...)
+               }
+               stampAgentId(installations, mark, fingerprint.ID)
+               fillMissingVersions(installations, mark, fingerprint)
+       }
+       return installations
+}
+
+func scanLocalServerPort(ctx context.Context, fingerprint *Fingerprint, port 
int) []Installation {
+       base, ok := fingerprint.LocalServer.Confirm(ctx, port)
+       if !ok {
+               return nil
+       }
+       version := sanitizeVersion(fingerprint.LocalServer.Version(ctx, base))
+
+       var result []Installation
+       seen := map[string]bool{}
+       for _, process := range localserver.Listeners(ctx, port) {
+               if ctx.Err() != nil {
+                       return result
+               }
+               key := strings.ToLower(process.Path)
+               if process.Path == "" || seen[key] {
+                       continue
+               }
+               seen[key] = true
+               if info, err := os.Stat(process.Path); err != nil || 
!info.Mode().IsRegular() {
+                       continue
+               }
+               result = append(result, Installation{
+                       Name: fingerprint.DisplayName, Version: version, Path: 
process.Path,
+                       InstallMethod: localServerInstallMethod, Owner: 
process.Owner,
+               })
+       }
+       return result
+}
diff --git a/agent/scan_unix.go b/agent/scan_unix.go
new file mode 100644
index 0000000..8883c06
--- /dev/null
+++ b/agent/scan_unix.go
@@ -0,0 +1,109 @@
+// Copyright 2025 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//go:build linux || darwin
+
+package agent
+
+import (
+       "os"
+       "os/user"
+       "path/filepath"
+       "strconv"
+       "syscall"
+
+       "github.com/apache/casbin-gateway/internal/hermes"
+)
+
+// homeDir is one user's home directory and the account that owns it.
+type homeDir struct {
+       owner string
+       path  string
+}
+
+// scanNative reports the per-user native installer layout: a launcher at
+// ~/.local/bin/<exec> pointing into ~/.local/share/<state>/versions/<version>.
+func scanNative(fingerprint *Fingerprint, home homeDir) []Installation {
+       if fingerprint.ExecName == "" || fingerprint.StateDir == "" {
+               return nil
+       }
+
+       launcher := filepath.Join(home.path, ".local", "bin", 
fingerprint.ExecName)
+       info, err := os.Stat(launcher)
+       if err != nil || !info.Mode().IsRegular() || info.Mode()&0o111 == 0 {
+               return nil
+       }
+
+       version := ""
+       if target, err := filepath.EvalSymlinks(launcher); err == nil {
+               version = versionUnderDir(filepath.Join(home.path, ".local", 
"share", fingerprint.StateDir, "versions"), target)
+       }
+       return []Installation{{
+               Name: fingerprint.DisplayName, Version: version, Path: launcher,
+               InstallMethod: "native", Owner: home.owner,
+       }}
+}
+
+// userNpmPatterns are the per-user Node package manager layouts that can hold 
a
+// globally installed agent package, plus any bundled runtime the agent 
declares.
+func userNpmPatterns(fingerprint *Fingerprint, home string) []string {
+       pkg := fingerprint.npmPackagePath()
+       patterns := []string{
+               filepath.Join(home, ".npm-global", "lib", "node_modules", pkg, 
"package.json"),
+               filepath.Join(home, ".nvm", "versions", "node", "*", "lib", 
"node_modules", pkg, "package.json"),
+               // fnm honours XDG when it is set and falls back to ~/.fnm 
otherwise.
+               filepath.Join(home, ".fnm", "node-versions", "*", 
"installation", "lib", "node_modules", pkg, "package.json"),
+               filepath.Join(home, ".local", "share", "fnm", "node-versions", 
"*", "installation", "lib", "node_modules", pkg, "package.json"),
+               filepath.Join(home, ".volta", "tools", "image", "packages", 
pkg, "lib", "node_modules", pkg, "package.json"),
+               filepath.Join(home, ".asdf", "installs", "nodejs", "*", "lib", 
"node_modules", pkg, "package.json"),
+               // pnpm groups its global packages under a store layout version.
+               filepath.Join(home, ".local", "share", "pnpm", "global", "*", 
"node_modules", pkg, "package.json"),
+       }
+       for _, dir := range fingerprint.ExtraUnixNpmDirs {
+               patterns = append(patterns, filepath.Join(home, 
filepath.FromSlash(dir), "node_modules", pkg, "package.json"))
+       }
+       return patterns
+}
+
+func fileOwner(path string) string {
+       info, err := os.Stat(path)
+       if err != nil {
+               return "root"
+       }
+       stat, ok := info.Sys().(*syscall.Stat_t)
+       if !ok {
+               return "root"
+       }
+       id := strconv.FormatUint(uint64(stat.Uid), 10)
+       account, err := user.LookupId(id)
+       if err != nil {
+               return id
+       }
+       return account.Username
+}
+
+func scanHermesUnix(home homeDir, launcher string, roots ...string) 
[]Installation {
+       info, err := os.Stat(launcher)
+       if err != nil || !info.Mode().IsRegular() || info.Mode()&0o111 == 0 {
+               return nil
+       }
+       if len(roots) == 0 {
+               roots = []string{filepath.Join(home.path, ".hermes", 
hermes.ProjectDir)}
+               if currentHome, homeErr := os.UserHomeDir(); homeErr == nil &&
+                       sameHermesPath(currentHome, home.path) && 
os.Getenv("HERMES_HOME") != "" {
+                       roots = 
[]string{filepath.Join(os.Getenv("HERMES_HOME"), hermes.ProjectDir)}
+               }
+       }
+       return hermesInstallation(launcher, home.owner, roots...)
+}
diff --git a/agent/scan_windows.go b/agent/scan_windows.go
new file mode 100644
index 0000000..f9feb43
--- /dev/null
+++ b/agent/scan_windows.go
@@ -0,0 +1,333 @@
+// Copyright 2025 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//go:build windows
+
+package agent
+
+import (
+       "context"
+       "os"
+       "os/user"
+       "path/filepath"
+       "strings"
+
+       "github.com/apache/casbin-gateway/internal/hermes"
+       "golang.org/x/sys/windows/registry"
+)
+
+// homeDir is one user's profile directory and the account that owns it.
+type homeDir struct {
+       owner string
+       path  string
+}
+
+// scan finds installations of every known agent in known Windows layouts. It
+// neither executes discovered binaries nor walks arbitrary filesystem roots.
+func scan(ctx context.Context) []Installation {
+       homes := windowsHomes(ctx)
+
+       var installations []Installation
+       for i := range fingerprints {
+               fingerprint := &fingerprints[i]
+               if ctx.Err() != nil {
+                       return installations
+               }
+               mark := len(installations)
+               for _, home := range homes {
+                       if ctx.Err() != nil {
+                               return installations
+                       }
+                       installations = append(installations, 
scanWindowsNative(fingerprint, home)...)
+                       installations = append(installations, 
scanWindowsWinget(ctx, fingerprint, home)...)
+                       installations = append(installations, 
scanWindowsNpm(ctx, fingerprint, home)...)
+                       installations = append(installations, 
scanWindowsUserPrograms(ctx, fingerprint, home)...)
+               }
+               installations = append(installations, scanWindowsDesktop(ctx, 
fingerprint, homes)...)
+               installations = append(installations, scanMachineWinget(ctx, 
fingerprint)...)
+               installations = append(installations, scanMachinePrograms(ctx, 
fingerprint)...)
+               stampAgentId(installations, mark, fingerprint.ID)
+               fillMissingVersions(installations, mark, fingerprint)
+       }
+       for _, home := range homes {
+               if ctx.Err() != nil {
+                       return installations
+               }
+               installations = append(installations, 
scanHermesWindows(home)...)
+       }
+       installations = append(installations, scanHermesOnPath()...)
+
+       // Last, so that an agent found both on disk and by its port keeps the
+       // richer install-layout row when the two resolve to the same 
executable.
+       installations = append(installations, scanLocalServers(ctx)...)
+
+       installations = 
expandSharedCodexWindowsInstallations(dedupeInstallations(installations), homes)
+       // Whatever layout an installation came from, its launcher may carry a
+       // version resource, so fall back to that rather than reporting no 
version.
+       for i := range installations {
+               if ctx.Err() != nil {
+                       return installations
+               }
+               if installations[i].Version == "" {
+                       installations[i].Version = 
executableVersion(installations[i].Path)
+               }
+       }
+       return installations
+}
+
+func windowsHomes(ctx context.Context) []homeDir {
+       seen := map[string]bool{}
+       var homes []homeDir
+       add := func(owner, path string) {
+               path = filepath.Clean(path)
+               if info, err := os.Stat(path); err != nil || !info.IsDir() {
+                       return
+               }
+               key := strings.ToLower(path)
+               if seen[key] {
+                       return
+               }
+               seen[key] = true
+               if owner == "" {
+                       owner = filepath.Base(path)
+               }
+               homes = append(homes, homeDir{owner: owner, path: path})
+       }
+
+       const profileList = `SOFTWARE\Microsoft\Windows 
NT\CurrentVersion\ProfileList`
+       key, err := registry.OpenKey(registry.LOCAL_MACHINE, profileList, 
registry.ENUMERATE_SUB_KEYS)
+       if err == nil {
+               defer key.Close()
+               if sids, err := key.ReadSubKeyNames(-1); err == nil {
+                       for _, sid := range sids {
+                               if ctx.Err() != nil {
+                                       return homes
+                               }
+                               profile, err := registry.OpenKey(key, sid, 
registry.QUERY_VALUE)
+                               if err != nil {
+                                       continue
+                               }
+                               path, _, readErr := 
profile.GetStringValue("ProfileImagePath")
+                               profile.Close()
+                               if readErr != nil {
+                                       continue
+                               }
+                               if expanded, err := 
registry.ExpandString(path); err == nil {
+                                       path = expanded
+                               }
+                               owner := ""
+                               if account, err := user.LookupId(sid); err == 
nil {
+                                       owner = account.Username
+                               }
+                               add(owner, path)
+                       }
+               }
+       }
+
+       if home, err := os.UserHomeDir(); err == nil {
+               owner := ""
+               if account, err := user.Current(); err == nil {
+                       owner = account.Username
+               }
+               add(owner, home)
+       }
+       return homes
+}
+
+// appDataDir respects relocated AppData paths for the current user.
+func appDataDir(home homeDir, kind, variable string) string {
+       if current, err := os.UserHomeDir(); err == nil && 
strings.EqualFold(filepath.Clean(current), filepath.Clean(home.path)) {
+               if configured := os.Getenv(variable); configured != "" {
+                       return configured
+               }
+       }
+       return filepath.Join(home.path, "AppData", kind)
+}
+
+func localAppData(home homeDir) string   { return appDataDir(home, "Local", 
"LOCALAPPDATA") }
+func roamingAppData(home homeDir) string { return appDataDir(home, "Roaming", 
"APPDATA") }
+
+func scanHermesWindows(home homeDir) []Installation {
+       hermesHome := filepath.Join(localAppData(home), "hermes")
+       if current, err := os.UserHomeDir(); err == nil &&
+               strings.EqualFold(filepath.Clean(current), 
filepath.Clean(home.path)) {
+               if configured := os.Getenv("HERMES_HOME"); configured != "" {
+                       hermesHome = configured
+               }
+       }
+       root := filepath.Join(hermesHome, hermes.ProjectDir)
+       launcher := filepath.Join(root, "venv", "Scripts", 
hermes.ExecName+".exe")
+       return hermesInstallation(launcher, home.owner, root)
+}
+
+// scanWindowsNative reports the per-user native installer layout: a launcher 
at
+// %USERPROFILE%\.local\bin\<exec>.exe backed by a versioned payload directory.
+func scanWindowsNative(fingerprint *Fingerprint, home homeDir) []Installation {
+       if fingerprint.ExecName == "" || fingerprint.StateDir == "" {
+               return nil
+       }
+
+       launcher := filepath.Join(home.path, ".local", "bin", 
fingerprint.ExecName+".exe")
+       launcherInfo, err := os.Stat(launcher)
+       if err != nil || !launcherInfo.Mode().IsRegular() {
+               return nil
+       }
+
+       version := ""
+       versionsDir := filepath.Join(home.path, ".local", "share", 
fingerprint.StateDir, "versions")
+       if target, err := filepath.EvalSymlinks(launcher); err == nil {
+               version = versionUnderDir(versionsDir, target)
+       }
+       // Windows installs may hard-link rather than symlink the launcher, in 
which
+       // case the version is only recoverable by identity against the 
payloads.
+       if version == "" {
+               if entries, err := os.ReadDir(versionsDir); err == nil {
+                       for _, entry := range entries {
+                               candidate, err := entry.Info()
+                               if err == nil && candidate.Mode().IsRegular() 
&& os.SameFile(launcherInfo, candidate) {
+                                       version = entry.Name()
+                                       break
+                               }
+                       }
+               }
+       }
+       return []Installation{{
+               Name: fingerprint.DisplayName, Version: version, Path: launcher,
+               InstallMethod: "native", Owner: home.owner,
+       }}
+}
+
+func scanWindowsWinget(ctx context.Context, fingerprint *Fingerprint, home 
homeDir) []Installation {
+       root := filepath.Join(localAppData(home), "Microsoft", "WinGet", 
"Packages")
+       return scanWingetPackages(ctx, fingerprint, root, home.owner)
+}
+
+func scanMachineWinget(ctx context.Context, fingerprint *Fingerprint) 
[]Installation {
+       var result []Installation
+       seen := map[string]bool{}
+       for _, variable := range []string{"ProgramFiles", "ProgramFiles(x86)"} {
+               if ctx.Err() != nil {
+                       return result
+               }
+               base := os.Getenv(variable)
+               if base == "" {
+                       continue
+               }
+               root := filepath.Join(base, "WinGet", "Packages")
+               key := strings.ToLower(filepath.Clean(root))
+               if seen[key] {
+                       continue
+               }
+               seen[key] = true
+               result = append(result, scanWingetPackages(ctx, fingerprint, 
root, "SYSTEM")...)
+       }
+       return result
+}
+
+// scanWindowsUserPrograms checks known per-user installer directories.
+func scanWindowsUserPrograms(ctx context.Context, fingerprint *Fingerprint, 
home homeDir) []Installation {
+       local := localAppData(home)
+       result := scanWindowsInstallDirs(ctx, fingerprint, filepath.Join(local, 
"Programs"), fingerprint.WindowsProgramDirs, home.owner, "installer")
+       return append(result, scanWindowsInstallDirs(ctx, fingerprint, local, 
fingerprint.WindowsUserDirs, home.owner, "native")...)
+}
+
+func scanMachinePrograms(ctx context.Context, fingerprint *Fingerprint) 
[]Installation {
+       var result []Installation
+       seen := map[string]bool{}
+       for _, variable := range []string{"ProgramFiles", "ProgramFiles(x86)"} {
+               if ctx.Err() != nil {
+                       return result
+               }
+               root := os.Getenv(variable)
+               if root == "" {
+                       continue
+               }
+               key := strings.ToLower(filepath.Clean(root))
+               if seen[key] {
+                       continue
+               }
+               seen[key] = true
+               result = append(result, scanWindowsInstallDirs(ctx, 
fingerprint, root, fingerprint.WindowsProgramDirs, "SYSTEM", "installer")...)
+       }
+       return result
+}
+
+// scanWindowsInstallDirs reports an installer layout: one directory per agent
+// holding the whole application, with the launcher at its root.
+func scanWindowsInstallDirs(ctx context.Context, fingerprint *Fingerprint, 
root string, dirs []string, owner, method string) []Installation {
+       if fingerprint.ExecName == "" || root == "" {
+               return nil
+       }
+
+       var result []Installation
+       for _, dir := range dirs {
+               if ctx.Err() != nil {
+                       return result
+               }
+               installDir := filepath.Join(root, filepath.FromSlash(dir))
+               executable := filepath.Join(installDir, 
fingerprint.ExecName+".exe")
+               if info, err := os.Stat(executable); err != nil || 
!info.Mode().IsRegular() {
+                       continue
+               }
+               result = append(result, Installation{
+                       Name: fingerprint.DisplayName, Path: executable,
+                       InstallMethod: method, Owner: owner,
+               })
+       }
+       return result
+}
+
+func scanWingetPackages(ctx context.Context, fingerprint *Fingerprint, root, 
owner string) []Installation {
+       if fingerprint.WingetPackage == "" || fingerprint.ExecName == "" {
+               return nil
+       }
+
+       // winget appends an install-source hash to the package identifier.
+       packages, _ := filepath.Glob(filepath.Join(root, 
fingerprint.WingetPackage+"_*"))
+       var result []Installation
+       for _, packageDir := range packages {
+               if ctx.Err() != nil {
+                       return result
+               }
+               executable := filepath.Join(packageDir, 
fingerprint.ExecName+".exe")
+               if info, err := os.Stat(executable); err == nil && 
info.Mode().IsRegular() {
+                       result = append(result, Installation{
+                               Name: fingerprint.DisplayName, Path: executable,
+                               InstallMethod: "winget", Owner: owner,
+                       })
+               }
+       }
+       return result
+}
+
+func scanWindowsNpm(ctx context.Context, fingerprint *Fingerprint, home 
homeDir) []Installation {
+       roaming := roamingAppData(home)
+       local := localAppData(home)
+       pkg := fingerprint.npmPackagePath()
+       patterns := []string{
+               filepath.Join(roaming, "npm", "node_modules", pkg, 
"package.json"),
+               filepath.Join(roaming, "nvm", "*", "node_modules", pkg, 
"package.json"),
+               filepath.Join(roaming, "fnm", "node-versions", "*", 
"installation", "node_modules", pkg, "package.json"),
+               // A Volta package image mirrors the npm prefix layout, which 
on Windows
+               // has no "lib" level; match both so a relocated image still 
resolves.
+               filepath.Join(local, "Volta", "tools", "image", "packages", 
pkg, "node_modules", pkg, "package.json"),
+               filepath.Join(local, "Volta", "tools", "image", "packages", 
pkg, "lib", "node_modules", pkg, "package.json"),
+               // pnpm groups its global packages under a store layout version.
+               filepath.Join(local, "pnpm", "global", "*", "node_modules", 
pkg, "package.json"),
+       }
+       for _, dir := range fingerprint.ExtraWindowsNpmDirs {
+               patterns = append(patterns, filepath.Join(local, 
filepath.FromSlash(dir), "node_modules", pkg, "package.json"))
+       }
+       return scanNpmPatterns(ctx, fingerprint, patterns, home.owner, nil)
+}
diff --git a/agent/scan_windows_desktop.go b/agent/scan_windows_desktop.go
new file mode 100644
index 0000000..655ec19
--- /dev/null
+++ b/agent/scan_windows_desktop.go
@@ -0,0 +1,153 @@
+// Copyright 2025 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//go:build windows
+
+package agent
+
+import (
+       "context"
+       "os"
+       "os/user"
+       "path/filepath"
+       "runtime"
+       "strings"
+       "unsafe"
+
+       "golang.org/x/sys/windows"
+)
+
+var (
+       kernel32                   = windows.NewLazySystemDLL("kernel32.dll")
+       getPackagesByPackageFamily = 
kernel32.NewProc("GetPackagesByPackageFamily")
+       getPackagePathByFullName   = 
kernel32.NewProc("GetPackagePathByFullName")
+)
+
+// scanWindowsDesktop covers the two ways a desktop app ships on Windows: as an
+// MSIX package registered with the OS, or as a per-user standalone installer.
+func scanWindowsDesktop(ctx context.Context, fingerprint *Fingerprint, homes 
[]homeDir) []Installation {
+       result := scanMSIX(ctx, fingerprint)
+       for _, home := range homes {
+               if ctx.Err() != nil {
+                       return result
+               }
+               result = append(result, scanDesktopInstaller(fingerprint, 
home)...)
+       }
+       return result
+}
+
+func scanDesktopInstaller(fingerprint *Fingerprint, home homeDir) 
[]Installation {
+       if fingerprint.DesktopInstallerDir == "" || fingerprint.ExecName == "" {
+               return nil
+       }
+
+       executable := filepath.Join(localAppData(home), 
fingerprint.DesktopInstallerDir, fingerprint.ExecName+".exe")
+       if info, err := os.Stat(executable); err != nil || 
!info.Mode().IsRegular() {
+               return nil
+       }
+       return []Installation{{
+               Name: fingerprint.DisplayName, Path: executable,
+               InstallMethod: "desktop-installer", Owner: home.owner,
+       }}
+}
+
+func scanMSIX(ctx context.Context, fingerprint *Fingerprint) []Installation {
+       if fingerprint.MSIXFamily == "" || fingerprint.ExecName == "" {
+               return nil
+       }
+       fullNames := packageFullNames(fingerprint.MSIXFamily)
+       if len(fullNames) == 0 {
+               return nil
+       }
+
+       owner := ""
+       if account, err := user.Current(); err == nil {
+               owner = account.Username
+       }
+       var result []Installation
+       for _, fullName := range fullNames {
+               if ctx.Err() != nil {
+                       return result
+               }
+               root := packagePath(fullName)
+               if root == "" {
+                       continue
+               }
+               executable := filepath.Join(root, "app", 
fingerprint.ExecName+".exe")
+               if info, err := os.Stat(executable); err != nil || 
!info.Mode().IsRegular() {
+                       continue
+               }
+               // A package full name is 
"<Name>_<Version>_<Arch>_<...>_<PublisherId>".
+               version := ""
+               if fields := strings.Split(fullName, "_"); len(fields) > 1 {
+                       version = fields[1]
+               }
+               result = append(result, Installation{
+                       Name: fingerprint.DisplayName, Version: version, Path: 
executable,
+                       InstallMethod: "msix", Owner: owner,
+               })
+       }
+       return result
+}
+
+func packageFullNames(family string) []string {
+       familyName, err := windows.UTF16PtrFromString(family)
+       if err != nil {
+               return nil
+       }
+       var count, bufferLength uint32
+       code, _, _ := getPackagesByPackageFamily.Call(
+               uintptr(unsafe.Pointer(familyName)), 
uintptr(unsafe.Pointer(&count)), 0,
+               uintptr(unsafe.Pointer(&bufferLength)), 0,
+       )
+       if windows.Errno(code) != windows.ERROR_INSUFFICIENT_BUFFER || count == 
0 || bufferLength == 0 {
+               return nil
+       }
+
+       names := make([]*uint16, count)
+       buffer := make([]uint16, bufferLength)
+       code, _, _ = getPackagesByPackageFamily.Call(
+               uintptr(unsafe.Pointer(familyName)), 
uintptr(unsafe.Pointer(&count)), uintptr(unsafe.Pointer(&names[0])),
+               uintptr(unsafe.Pointer(&bufferLength)), 
uintptr(unsafe.Pointer(&buffer[0])),
+       )
+       if code != 0 {
+               return nil
+       }
+       result := make([]string, 0, count)
+       for _, name := range names[:count] {
+               result = append(result, windows.UTF16PtrToString(name))
+       }
+       runtime.KeepAlive(buffer)
+       return result
+}
+
+func packagePath(fullName string) string {
+       name, err := windows.UTF16PtrFromString(fullName)
+       if err != nil {
+               return ""
+       }
+       var length uint32
+       code, _, _ := 
getPackagePathByFullName.Call(uintptr(unsafe.Pointer(name)), 
uintptr(unsafe.Pointer(&length)), 0)
+       if windows.Errno(code) != windows.ERROR_INSUFFICIENT_BUFFER || length 
== 0 {
+               return ""
+       }
+       buffer := make([]uint16, length)
+       code, _, _ = getPackagePathByFullName.Call(
+               uintptr(unsafe.Pointer(name)), 
uintptr(unsafe.Pointer(&length)), uintptr(unsafe.Pointer(&buffer[0])),
+       )
+       if code != 0 {
+               return ""
+       }
+       return windows.UTF16ToString(buffer)
+}
diff --git a/agent/version.go b/agent/version.go
new file mode 100644
index 0000000..ac07937
--- /dev/null
+++ b/agent/version.go
@@ -0,0 +1,127 @@
+// Copyright 2025 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package agent
+
+import (
+       "bytes"
+       "debug/buildinfo"
+       "os"
+       "path/filepath"
+       "regexp"
+       "strings"
+)
+
+// maxVersionFileSize limits reads from version files.
+const maxVersionFileSize = 4 * 1024
+
+var pseudoVersion = regexp.MustCompile(`\d{14}-[0-9a-f]{12}`)
+
+var describeSuffix = regexp.MustCompile(`-\d+-g[0-9a-f]+$`)
+
+// executableBuildVersion reads release metadata without running the binary.
+func executableBuildVersion(path, module, versionVar string) string {
+       if path == "" || module == "" {
+               return ""
+       }
+       info, err := buildinfo.ReadFile(path)
+       if err != nil || info == nil || info.Main.Path != module {
+               return ""
+       }
+       if versionVar != "" {
+               for _, setting := range info.Settings {
+                       if setting.Key != "-ldflags" {
+                               continue
+                       }
+                       if version := ldflagsVersion(setting.Value, 
versionVar); version != "" {
+                               return version
+                       }
+               }
+       }
+       return cleanReleaseVersion(info.Main.Version)
+}
+
+func ldflagsVersion(ldflags, versionVar string) string {
+       prefix := versionVar + "="
+       for _, field := range strings.Fields(ldflags) {
+               field = strings.TrimPrefix(field, "-X=")
+               if value, ok := strings.CutPrefix(field, prefix); ok {
+                       return sanitizeVersion(value)
+               }
+       }
+       return ""
+}
+
+func cleanReleaseVersion(version string) string {
+       version = sanitizeVersion(version)
+       if version == "" || strings.Contains(version, "+") || 
pseudoVersion.MatchString(version) {
+               return ""
+       }
+       return version
+}
+
+func sanitizeVersion(version string) string {
+       switch version = strings.TrimSpace(version); version {
+       case "", "dev", "unknown", "(devel)":
+               return ""
+       }
+       if len(version) > 1 && version[0] == 'v' && version[1] >= '0' && 
version[1] <= '9' {
+               version = version[1:]
+       }
+       version = strings.TrimSuffix(version, "-dirty")
+       version = describeSuffix.ReplaceAllString(version, "")
+       return version
+}
+
+// executableVersionFile reads a small version file next to the binary.
+func executableVersionFile(binaryPath, fileName string) string {
+       if binaryPath == "" || fileName == "" {
+               return ""
+       }
+       resolved := binaryPath
+       if target, err := filepath.EvalSymlinks(binaryPath); err == nil {
+               resolved = target
+       }
+       path := filepath.Join(filepath.Dir(resolved), fileName)
+
+       info, err := os.Stat(path)
+       if err != nil || !info.Mode().IsRegular() || info.Size() > 
maxVersionFileSize {
+               return ""
+       }
+       data, err := os.ReadFile(path)
+       if err != nil {
+               return ""
+       }
+       if newline := bytes.IndexByte(data, '\n'); newline >= 0 {
+               data = data[:newline]
+       }
+       return sanitizeVersion(string(data))
+}
+
+func fillMissingVersions(installations []Installation, mark int, fingerprint 
*Fingerprint) {
+       if fingerprint.BuildInfoModule == "" && fingerprint.VersionFile == "" {
+               return
+       }
+       for i := mark; i < len(installations); i++ {
+               if installations[i].Version != "" {
+                       continue
+               }
+               version := executableBuildVersion(
+                       installations[i].Path, fingerprint.BuildInfoModule, 
fingerprint.BuildInfoVersionVar)
+               if version == "" {
+                       version = executableVersionFile(installations[i].Path, 
fingerprint.VersionFile)
+               }
+               installations[i].Version = version
+       }
+}
diff --git a/agent/version_windows.go b/agent/version_windows.go
new file mode 100644
index 0000000..c62f740
--- /dev/null
+++ b/agent/version_windows.go
@@ -0,0 +1,73 @@
+// Copyright 2025 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//go:build windows
+
+package agent
+
+import (
+       "fmt"
+       "strings"
+       "unsafe"
+
+       "golang.org/x/sys/windows"
+)
+
+// executableVersion reads a Windows executable's version resource.
+func executableVersion(path string) string {
+       size, err := windows.GetFileVersionInfoSize(path, nil)
+       if err != nil || size == 0 {
+               return ""
+       }
+       block := make([]byte, size)
+       if windows.GetFileVersionInfo(path, 0, size, unsafe.Pointer(&block[0])) 
!= nil {
+               return ""
+       }
+
+       for _, language := range versionLanguages(block) {
+               for _, name := range []string{"ProductVersion", "FileVersion"} {
+                       if value := versionString(block, language, name); value 
!= "" {
+                               return value
+                       }
+               }
+       }
+       return ""
+}
+
+func versionLanguages(block []byte) []string {
+       var translations *[2]uint16
+       var size uint32
+       err := windows.VerQueryValue(unsafe.Pointer(&block[0]), 
`\VarFileInfo\Translation`, unsafe.Pointer(&translations), &size)
+       if err != nil || translations == nil || size < 4 {
+               return []string{"040904b0"}
+       }
+
+       count := int(size / 4)
+       pairs := unsafe.Slice(translations, count)
+       languages := make([]string, 0, count)
+       for _, pair := range pairs {
+               languages = append(languages, fmt.Sprintf("%04x%04x", pair[0], 
pair[1]))
+       }
+       return languages
+}
+
+func versionString(block []byte, language, name string) string {
+       var value *uint16
+       var size uint32
+       subBlock := `\StringFileInfo\` + language + `\` + name
+       if windows.VerQueryValue(unsafe.Pointer(&block[0]), subBlock, 
unsafe.Pointer(&value), &size) != nil || size == 0 {
+               return ""
+       }
+       return strings.TrimSpace(windows.UTF16ToString(unsafe.Slice(value, 
size)))
+}
diff --git a/controllers/agent.go b/controllers/agent.go
new file mode 100644
index 0000000..9493c1c
--- /dev/null
+++ b/controllers/agent.go
@@ -0,0 +1,32 @@
+// Copyright 2025 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package controllers
+
+import "github.com/apache/casbin-gateway/agent"
+
+// GetAgents scans known installation locations and returns the AI agents
+// installed in the environment where Casbin Gateway is running.
+func (c *ApiController) GetAgents() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       installations, err := agent.Scan(c.GetString("refresh") == "true")
+       if err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       c.ResponseOk(installations)
+}
diff --git a/go.mod b/go.mod
index 091fd8f..e2d3b02 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
 module github.com/apache/casbin-gateway
 
-go 1.16
+go 1.20
 
 require (
        github.com/Masterminds/squirrel v1.5.4
@@ -22,7 +22,97 @@ require (
        github.com/xorm-io/core v0.7.4
        github.com/xorm-io/xorm v1.1.6
        golang.org/x/net v0.21.0
+       golang.org/x/sys v0.17.0
        golang.org/x/text v0.14.0
        golang.org/x/time v0.3.0
        modernc.org/sqlite v1.18.1
 )
+
+require (
+       dario.cat/mergo v1.0.0 // indirect
+       github.com/BurntSushi/toml v0.3.1 // indirect
+       github.com/Microsoft/go-winio v0.6.1 // indirect
+       github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 // 
indirect
+       github.com/acomagu/bufpipe v1.0.4 // indirect
+       github.com/beorn7/perks v1.0.1 // indirect
+       github.com/cenkalti/backoff/v4 v4.1.1 // indirect
+       github.com/cespare/xxhash/v2 v2.1.1 // indirect
+       github.com/cloudflare/circl v1.3.3 // indirect
+       github.com/corazawaf/libinjection-go v0.1.3 // indirect
+       github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect
+       github.com/cyphar/filepath-securejoin v0.2.4 // indirect
+       github.com/emirpasic/gods v1.18.1 // indirect
+       github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
+       github.com/go-git/go-billy/v5 v5.5.0 // indirect
+       github.com/golang-jwt/jwt/v4 v4.5.0 // indirect
+       github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe // 
indirect
+       github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // 
indirect
+       github.com/golang/protobuf v1.5.3 // indirect
+       github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db // indirect
+       github.com/gomodule/redigo v2.0.0+incompatible // indirect
+       github.com/hashicorp/golang-lru v0.5.4 // indirect
+       github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // 
indirect
+       github.com/jmespath/go-jmespath v0.4.0 // indirect
+       github.com/json-iterator/go v1.1.11 // indirect
+       github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // 
indirect
+       github.com/kevinburke/ssh_config v1.2.0 // indirect
+       github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
+       github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
+       github.com/likexian/gokit v0.25.13 // indirect
+       github.com/magefile/mage v1.15.0 // indirect
+       github.com/mattn/go-isatty v0.0.12 // indirect
+       github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
+       github.com/miekg/dns v1.1.57 // indirect
+       github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // 
indirect
+       github.com/modern-go/reflect2 v1.0.1 // indirect
+       github.com/petar-dambovaliev/aho-corasick 
v0.0.0-20230725210150-fb29fc3c913e // indirect
+       github.com/pingcap/errors v0.11.5-0.20210425183316-da1aaba5fb63 // 
indirect
+       github.com/pingcap/log v0.0.0-20210625125904-98ed8e2eb1c7 // indirect
+       github.com/pingcap/tidb/parser v0.0.0-20221126021158-6b02a5d8ba7d // 
indirect
+       github.com/pjbgf/sha1cd v0.3.0 // indirect
+       github.com/prometheus/client_golang v1.7.0 // indirect
+       github.com/prometheus/client_model v0.2.0 // indirect
+       github.com/prometheus/common v0.10.0 // indirect
+       github.com/prometheus/procfs v0.1.3 // indirect
+       github.com/rainycape/memcache v0.0.0-20150622160815-1031fa0ce2f2 // 
indirect
+       github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // 
indirect
+       github.com/russross/blackfriday/v2 v2.0.1 // indirect
+       github.com/sergi/go-diff v1.1.0 // indirect
+       github.com/shiena/ansicolor v0.0.0-20151119151921-a422bbe96644 // 
indirect
+       github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24 // 
indirect
+       github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
+       github.com/siddontang/go v0.0.0-20180604090527-bdc77568d726 // indirect
+       github.com/skeema/knownhosts v1.2.0 // indirect
+       github.com/syndtr/goleveldb v1.0.0 // indirect
+       github.com/tidwall/gjson v1.17.0 // indirect
+       github.com/tidwall/match v1.1.1 // indirect
+       github.com/tidwall/pretty v1.2.1 // indirect
+       github.com/urfave/cli v1.22.5 // indirect
+       github.com/xanzy/ssh-agent v0.3.3 // indirect
+       github.com/xorm-io/builder v0.3.13 // indirect
+       go.uber.org/atomic v1.7.0 // indirect
+       go.uber.org/multierr v1.6.0 // indirect
+       go.uber.org/zap v1.18.1 // indirect
+       golang.org/x/crypto v0.19.0 // indirect
+       golang.org/x/mod v0.14.0 // indirect
+       golang.org/x/oauth2 v0.13.0 // indirect
+       golang.org/x/sync v0.6.0 // indirect
+       golang.org/x/tools v0.15.0 // indirect
+       google.golang.org/appengine v1.6.7 // indirect
+       google.golang.org/protobuf v1.31.0 // indirect
+       gopkg.in/ini.v1 v1.62.0 // indirect
+       gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect
+       gopkg.in/square/go-jose.v2 v2.6.0 // indirect
+       gopkg.in/warnings.v0 v0.1.2 // indirect
+       gopkg.in/yaml.v2 v2.4.0 // indirect
+       lukechampine.com/uint128 v1.1.1 // indirect
+       modernc.org/cc/v3 v3.36.0 // indirect
+       modernc.org/ccgo/v3 v3.16.8 // indirect
+       modernc.org/libc v1.16.19 // indirect
+       modernc.org/mathutil v1.4.1 // indirect
+       modernc.org/memory v1.1.1 // indirect
+       modernc.org/opt v0.1.1 // indirect
+       modernc.org/strutil v1.1.1 // indirect
+       modernc.org/token v1.0.0 // indirect
+       rsc.io/binaryregexp v0.2.0 // indirect
+)
diff --git a/go.sum b/go.sum
index 317b523..1716cad 100644
--- a/go.sum
+++ b/go.sum
@@ -664,6 +664,7 @@ github.com/census-instrumentation/opencensus-proto 
v0.3.0/go.mod h1:f6KPmirojxKA
 github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod 
h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw=
 github.com/cespare/xxhash v1.1.0 
h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
 github.com/cespare/xxhash v1.1.0/go.mod 
h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
+github.com/cespare/xxhash/v2 v2.1.1 
h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY=
 github.com/cespare/xxhash/v2 v2.1.1/go.mod 
h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
 github.com/cespare/xxhash/v2 v2.2.0 
h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
 github.com/cespare/xxhash/v2 v2.2.0/go.mod 
h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
@@ -830,6 +831,7 @@ github.com/golang/protobuf v1.5.2/go.mod 
h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiu
 github.com/golang/protobuf v1.5.3 
h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
 github.com/golang/protobuf v1.5.3/go.mod 
h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
 github.com/golang/snappy v0.0.0-20170215233205-553a64147049/go.mod 
h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
+github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db 
h1:woRePGFeVFfLKN/pOkfl+p/TAqKOfFu+7KPlMVpok/w=
 github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod 
h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
 github.com/golang/snappy v0.0.3/go.mod 
h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
 github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
@@ -915,6 +917,7 @@ github.com/hashicorp/golang-lru v0.5.0/go.mod 
h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ
 github.com/hashicorp/golang-lru v0.5.1/go.mod 
h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
 github.com/hashicorp/golang-lru v0.5.4 
h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
 github.com/hashicorp/golang-lru v0.5.4/go.mod 
h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
+github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
 github.com/hpcloud/tail v1.0.0/go.mod 
h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
 github.com/hsluoyz/modsecurity-go v0.0.7 
h1:W5ChaDrm4kM/UhHxoD2zyxQ+6s5kSj6cVftDFgdFzBM=
 github.com/hsluoyz/modsecurity-go v0.0.7/go.mod 
h1:hi81ySzwvlQFd5pip9c3uwXHDAW9ayxwLbt8ufxRkdY=
@@ -986,6 +989,7 @@ github.com/magefile/mage v1.15.0 
h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg=
 github.com/magefile/mage v1.15.0/go.mod 
h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
 github.com/matryer/is v1.2.0 h1:92UTHpy8CDwaJ08GqLDzhhuixiBUUD1p3AU6PHddz4A=
 github.com/matryer/is v1.2.0/go.mod 
h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA=
+github.com/mattn/go-isatty v0.0.12 
h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
 github.com/mattn/go-isatty v0.0.12/go.mod 
h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
 github.com/mattn/go-isatty v0.0.16 
h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
 github.com/mattn/go-isatty v0.0.16/go.mod 
h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
@@ -1016,6 +1020,7 @@ github.com/nxadm/tail v1.4.8 
h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
 github.com/nxadm/tail v1.4.8/go.mod 
h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
 github.com/onsi/ginkgo v1.6.0/go.mod 
h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
 github.com/onsi/ginkgo v1.7.0/go.mod 
h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
+github.com/onsi/ginkgo v1.12.0 h1:Iw5WCbBcaAAd0fpRb1c9r5YCylv4XDoCSigm1zLevwU=
 github.com/onsi/ginkgo v1.12.0/go.mod 
h1:oUhWkIvk5aDxtKvDDuw8gItl8pKl42LzjC9KZE0HfGg=
 github.com/onsi/ginkgo v1.12.1/go.mod 
h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
 github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc=
@@ -1088,6 +1093,7 @@ github.com/prometheus/client_golang v1.7.0/go.mod 
h1:PY5Wy2awLA44sXw4AOSfFBetzPP
 github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod 
h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
 github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod 
h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
 github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod 
h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
+github.com/prometheus/client_model v0.2.0 
h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2dUR+/W/M=
 github.com/prometheus/client_model v0.2.0/go.mod 
h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
 github.com/prometheus/client_model v0.3.0 
h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4=
 github.com/prometheus/client_model v0.3.0/go.mod 
h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w=
@@ -1158,6 +1164,7 @@ github.com/stretchr/testify v1.5.1/go.mod 
h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5
 github.com/stretchr/testify v1.6.1/go.mod 
h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.7.0/go.mod 
h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.7.1/go.mod 
h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0 
h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
 github.com/stretchr/testify v1.8.0/go.mod 
h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
 github.com/stretchr/testify v1.8.1 
h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
 github.com/stretchr/testify v1.8.1/go.mod 
h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
@@ -1283,6 +1290,7 @@ golang.org/x/lint 
v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTk
 golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod 
h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
 golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod 
h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
 golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod 
h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20190930215403-16217165b5de 
h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs=
 golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod 
h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
 golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod 
h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
 golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod 
h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
@@ -1968,6 +1976,7 @@ gopkg.in/check.v1 
v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c 
h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod 
h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
 gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
+gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
 gopkg.in/fsnotify.v1 v1.4.7/go.mod 
h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
 gopkg.in/ini.v1 v1.42.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
 gopkg.in/ini.v1 v1.62.0 h1:duBzk771uxoUuOlyRLkHsygud9+5lrlGjdFBb4mSKDU=
@@ -2002,10 +2011,12 @@ honnef.co/go/tools v0.0.1-2019.2.3/go.mod 
h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt
 honnef.co/go/tools v0.0.1-2020.1.3/go.mod 
h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
 honnef.co/go/tools v0.0.1-2020.1.4/go.mod 
h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
 honnef.co/go/tools v0.1.3/go.mod 
h1:NgwopIslSNH47DimFoV78dnkksY2EFtX0ajyb3K/las=
+lukechampine.com/uint128 v1.1.1 h1:pnxCASz787iMf+02ssImqk6OLt+Z5QHMoZyUXR4z6JU=
 lukechampine.com/uint128 v1.1.1/go.mod 
h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
 lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
 lukechampine.com/uint128 v1.2.0/go.mod 
h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
 modernc.org/cc/v3 v3.31.5-0.20210308123301-7a3e9dab9009/go.mod 
h1:0R6jl1aZlIl2avnYfbfHBS1QB6/f+16mihBObaBC878=
+modernc.org/cc/v3 v3.36.0 h1:0kmRkTmqNidmu3c7BNDSdVHCxXCkWLmWmCIVX4LUboo=
 modernc.org/cc/v3 v3.36.0/go.mod 
h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI=
 modernc.org/cc/v3 v3.36.2/go.mod 
h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI=
 modernc.org/cc/v3 v3.36.3 h1:uISP3F66UlixxWEcKuIWERa4TwrZENHSL8tWxZz8bHg=
@@ -2015,6 +2026,7 @@ modernc.org/ccgo/v3 
v3.0.0-20220430103911-bc99d88307be/go.mod h1:bwdAnOoaIt8Ax9Y
 modernc.org/ccgo/v3 v3.9.0/go.mod 
h1:nQbgkn8mwzPdp4mm6BT6+p85ugQ7FrGgIcYaE7nSrpY=
 modernc.org/ccgo/v3 v3.16.4/go.mod 
h1:tGtX0gE9Jn7hdZFeU88slbTh1UtCYKusWOoCJuvkWsQ=
 modernc.org/ccgo/v3 v3.16.6/go.mod 
h1:tGtX0gE9Jn7hdZFeU88slbTh1UtCYKusWOoCJuvkWsQ=
+modernc.org/ccgo/v3 v3.16.8 h1:G0QNlTqI5uVgczBWfGKs7B++EPwCfXPWGD2MdeKloDs=
 modernc.org/ccgo/v3 v3.16.8/go.mod 
h1:zNjwkizS+fIFDrDjIAgBSCLkWbJuHF+ar3QRn+Z9aws=
 modernc.org/ccgo/v3 v3.16.9 h1:AXquSwg7GuMk11pIdw7fmO1Y/ybgazVkMhsZWCV0mHM=
 modernc.org/ccgo/v3 v3.16.9/go.mod 
h1:zNMzC9A9xeNUepy6KuZBbugn3c0Mc9TeiJO4lgvkJDo=
@@ -2032,6 +2044,7 @@ modernc.org/libc v1.8.0/go.mod 
h1:U1eq8YWr/Kc1RWCMFUWEdkTg8OTcfLw2kY8EDwl039w=
 modernc.org/libc v1.16.0/go.mod h1:N4LD6DBE9cf+Dzf9buBlzVJndKr/iJHG97vGLHYnb5A=
 modernc.org/libc v1.16.1/go.mod h1:JjJE0eu4yeK7tab2n4S1w8tlWd9MxXLRzheaRnAKymU=
 modernc.org/libc v1.16.17/go.mod 
h1:hYIV5VZczAmGZAnG15Vdngn5HSF5cSkbvfz2B7GRuVU=
+modernc.org/libc v1.16.19 h1:S8flPn5ZeXx6iw/8yNa986hwTQDrY8RXU7tObZuAozo=
 modernc.org/libc v1.16.19/go.mod 
h1:p7Mg4+koNjc8jkqwcoFBJx7tXkpj00G77X7A72jXPXA=
 modernc.org/libc v1.17.0/go.mod h1:XsgLldpP4aWlPlsjqKRdHPqCxCjISdHfM/yeWC5GyW0=
 modernc.org/libc v1.17.1 h1:Q8/Cpi36V/QBfuQaFVeisEBs3WqoGAJprZzmf7TfEYI=
@@ -2039,14 +2052,17 @@ modernc.org/libc v1.17.1/go.mod 
h1:FZ23b+8LjxZs7XtFMbSzL/EhPxNbfZbErxEHc7cbD9s=
 modernc.org/mathutil v1.0.0/go.mod 
h1:wU0vUrJsVWBZ4P6e7xtFJEhFSNsfRLJ8H458uRjg03k=
 modernc.org/mathutil v1.1.1/go.mod 
h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
 modernc.org/mathutil v1.2.2/go.mod 
h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
+modernc.org/mathutil v1.4.1 h1:ij3fYGe8zBF4Vu+g0oT7mB06r8sqGWKuJu1yXeR4by8=
 modernc.org/mathutil v1.4.1/go.mod 
h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
 modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
 modernc.org/mathutil v1.5.0/go.mod 
h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
 modernc.org/memory v1.0.4/go.mod 
h1:nV2OApxradM3/OVbs2/0OsP6nPfakXpi50C7dcoHXlc=
+modernc.org/memory v1.1.1 h1:bDOL0DIDLQv7bWhP3gMvIrnoFw+Eo6F7a2QK9HPDiFU=
 modernc.org/memory v1.1.1/go.mod 
h1:/0wo5ibyrQiaoUoH7f9D8dnglAmILJ5/cxZlRECf+Nw=
 modernc.org/memory v1.2.0/go.mod 
h1:/0wo5ibyrQiaoUoH7f9D8dnglAmILJ5/cxZlRECf+Nw=
 modernc.org/memory v1.2.1 h1:dkRh86wgmq/bJu2cAS2oqBCz/KsMZU7TUM4CibQ7eBs=
 modernc.org/memory v1.2.1/go.mod 
h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
+modernc.org/opt v0.1.1 h1:/0RX92k9vwVeDXj+Xn23DKp2VJubL7k8qNffND6qn3A=
 modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
 modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
 modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
@@ -2059,6 +2075,7 @@ modernc.org/sqlite v1.18.1 
h1:ko32eKt3jf7eqIkCgPAeHMBXw3riNSLhl2f3loEF7o8=
 modernc.org/sqlite v1.18.1/go.mod 
h1:6ho+Gow7oX5V+OiOQ6Tr4xeqbx13UZ6t+Fw9IRUG4d4=
 modernc.org/strutil v1.0.0/go.mod 
h1:lstksw84oURvj9y3tn8lGvRxyRC1S2+g5uuIzNfIOBs=
 modernc.org/strutil v1.1.0/go.mod 
h1:lstksw84oURvj9y3tn8lGvRxyRC1S2+g5uuIzNfIOBs=
+modernc.org/strutil v1.1.1 h1:xv+J1BXY3Opl2ALrBwyfEikFAj8pmqcpnfmuwUwcozs=
 modernc.org/strutil v1.1.1/go.mod 
h1:DE+MQQ/hjKBZS2zNInV5hhcipt5rLPWkmpbGeW5mmdw=
 modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY=
 modernc.org/strutil v1.1.3/go.mod 
h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
diff --git a/internal/hermes/inspect.go b/internal/hermes/inspect.go
new file mode 100644
index 0000000..88ce619
--- /dev/null
+++ b/internal/hermes/inspect.go
@@ -0,0 +1,165 @@
+// Copyright 2025 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package hermes validates Hermes Agent installations.
+package hermes
+
+import (
+       "fmt"
+       "os"
+       "path/filepath"
+       "strconv"
+       "strings"
+)
+
+const (
+       AgentID     = "hermes-agent"
+       DisplayName = "Hermes Agent"
+       ExecName    = "hermes"
+       ProjectDir  = "hermes-agent"
+       ProjectName = "hermes-agent"
+
+       maxProjectFileSize = 1024 * 1024
+       maxParentDepth     = 6
+)
+
+type Project struct {
+       Root    string
+       Version string
+}
+
+// InspectLauncher validates a launcher and locates its Hermes checkout.
+func InspectLauncher(launcher string, knownRoots ...string) (Project, error) {
+       info, err := os.Stat(launcher)
+       if err != nil || !info.Mode().IsRegular() {
+               return Project{}, fmt.Errorf("%s is not a regular Hermes 
launcher", launcher)
+       }
+
+       seen := map[string]bool{}
+       for _, candidate := range knownRoots {
+               if candidate == "" {
+                       continue
+               }
+               root := filepath.Clean(candidate)
+               seen[root] = true
+               if version, ok := projectVersion(root); ok {
+                       return Project{Root: root, Version: version}, nil
+               }
+       }
+
+       candidates := []string{filepath.Dir(launcher)}
+       if resolved, resolveErr := filepath.EvalSymlinks(launcher); resolveErr 
== nil {
+               candidates = append(candidates, filepath.Dir(resolved))
+       }
+
+       for _, candidate := range candidates {
+               for depth := 0; candidate != "" && depth <= maxParentDepth; 
depth++ {
+                       root := filepath.Clean(candidate)
+                       if seen[root] {
+                               break
+                       }
+                       seen[root] = true
+                       if version, ok := projectVersion(root); ok {
+                               return Project{Root: root, Version: version}, 
nil
+                       }
+                       parent := filepath.Dir(root)
+                       if parent == root {
+                               break
+                       }
+                       candidate = parent
+               }
+       }
+       return Project{}, fmt.Errorf("%s is not a validated Hermes Agent 
launcher", launcher)
+}
+
+func projectVersion(root string) (string, bool) {
+       path := filepath.Join(root, "pyproject.toml")
+       info, err := os.Stat(path)
+       if err != nil || !info.Mode().IsRegular() || info.Size() > 
maxProjectFileSize {
+               return "", false
+       }
+       data, err := os.ReadFile(path)
+       if err != nil {
+               return "", false
+       }
+
+       inProject := false
+       name, version := "", ""
+       for _, raw := range strings.Split(string(data), "\n") {
+               line := strings.TrimSpace(raw)
+               if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") 
{
+                       inProject = line == "[project]"
+                       continue
+               }
+               if !inProject {
+                       continue
+               }
+               key, value, ok := strings.Cut(line, "=")
+               if !ok {
+                       continue
+               }
+               switch strings.TrimSpace(key) {
+               case "name":
+                       name = tomlString(value)
+               case "version":
+                       version = tomlString(value)
+               }
+       }
+       return version, name == ProjectName && version != ""
+}
+
+func tomlString(value string) string {
+       value = strings.TrimSpace(value)
+       if len(value) < 2 || value[0] != '"' && value[0] != '\'' {
+               return ""
+       }
+       quote := value[0]
+       for index := 1; index < len(value); index++ {
+               if quote == '"' && value[index] == '\\' {
+                       index++
+                       continue
+               }
+               if value[index] != quote {
+                       continue
+               }
+               rest := strings.TrimSpace(value[index+1:])
+               if rest == "" || strings.HasPrefix(rest, "#") {
+                       return value[1:index]
+               }
+               return ""
+       }
+       return ""
+}
+
+func VersionAtLeast(actual, minimum string) bool {
+       parse := func(value string) [3]int {
+               var result [3]int
+               value = strings.TrimPrefix(strings.TrimSpace(value), "v")
+               for index, part := range strings.SplitN(value, ".", 
len(result)) {
+                       digits := strings.TrimLeftFunc(part, func(char rune) 
bool {
+                               return char >= '0' && char <= '9'
+                       })
+                       digits = part[:len(part)-len(digits)]
+                       result[index], _ = strconv.Atoi(digits)
+               }
+               return result
+       }
+       got, want := parse(actual), parse(minimum)
+       for index := range got {
+               if got[index] != want[index] {
+                       return got[index] > want[index]
+               }
+       }
+       return true
+}
diff --git a/localserver/listener_darwin.go b/localserver/listener_darwin.go
new file mode 100644
index 0000000..f75b5e5
--- /dev/null
+++ b/localserver/listener_darwin.go
@@ -0,0 +1,71 @@
+// Copyright 2025 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//go:build darwin
+
+package localserver
+
+import (
+       "context"
+       "os/exec"
+       "path/filepath"
+       "strconv"
+       "strings"
+       "time"
+)
+
+const (
+       lsofPath              = "/usr/sbin/lsof"
+       psPath                = "/bin/ps"
+       listenerCommandBudget = 3 * time.Second
+)
+
+// Listeners resolves listening processes through lsof and ps.
+func Listeners(ctx context.Context, port int) []Process {
+       // -t prints bare PIDs, one per line, of the listening sockets alone.
+       out, ok := listenerCommand(ctx, lsofPath, "-nP", "-t", 
"-iTCP:"+strconv.Itoa(port), "-sTCP:LISTEN")
+       if !ok {
+               return nil
+       }
+
+       var result []Process
+       seen := map[int]bool{}
+       for _, line := range strings.Fields(out) {
+               if ctx.Err() != nil {
+                       return result
+               }
+               pid, err := strconv.Atoi(line)
+               if err != nil || pid <= 0 || seen[pid] {
+                       continue
+               }
+               seen[pid] = true
+
+               // On macOS "comm" is the executable's full path, not just its 
name.
+               path, ok := listenerCommand(ctx, psPath, "-o", "comm=", "-p", 
line)
+               path = strings.TrimSpace(path)
+               if !ok || !filepath.IsAbs(path) {
+                       continue
+               }
+               owner, _ := listenerCommand(ctx, psPath, "-o", "user=", "-p", 
line)
+               result = append(result, Process{Pid: pid, Path: path, Owner: 
strings.TrimSpace(owner)})
+       }
+       return result
+}
+
+func listenerCommand(ctx context.Context, path string, args ...string) 
(string, bool) {
+       ctx, cancel := context.WithTimeout(ctx, listenerCommandBudget)
+       defer cancel()
+       out, err := exec.CommandContext(ctx, path, args...).Output()
+       return string(out), err == nil
+}
diff --git a/localserver/listener_linux.go b/localserver/listener_linux.go
new file mode 100644
index 0000000..47364bf
--- /dev/null
+++ b/localserver/listener_linux.go
@@ -0,0 +1,141 @@
+// Copyright 2025 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//go:build linux
+
+package localserver
+
+import (
+       "bufio"
+       "context"
+       "fmt"
+       "os"
+       "os/user"
+       "path/filepath"
+       "strconv"
+       "strings"
+       "syscall"
+)
+
+// tcpListen is the TCP_LISTEN state as /proc/net/tcp writes it.
+const tcpListen = "0A"
+
+// Listeners resolves listening socket inodes through procfs.
+func Listeners(ctx context.Context, port int) []Process {
+       inodes := listeningSocketInodes(ctx, port)
+       if len(inodes) == 0 {
+               return nil
+       }
+
+       var result []Process
+       for _, pid := range pidsHoldingInodes(ctx, inodes) {
+               if ctx.Err() != nil {
+                       return result
+               }
+               path, err := os.Readlink(fmt.Sprintf("/proc/%d/exe", pid))
+               if err != nil {
+                       continue
+               }
+               result = append(result, Process{
+                       Pid: pid, Path: path, Owner: processOwner(pid),
+               })
+       }
+       return result
+}
+
+// processOwner names the account a process runs as: /proc/<pid> is owned by 
it.
+func processOwner(pid int) string {
+       info, err := os.Stat(fmt.Sprintf("/proc/%d", pid))
+       if err != nil {
+               return ""
+       }
+       stat, ok := info.Sys().(*syscall.Stat_t)
+       if !ok {
+               return ""
+       }
+       id := strconv.FormatUint(uint64(stat.Uid), 10)
+       account, err := user.LookupId(id)
+       if err != nil {
+               return id
+       }
+       return account.Username
+}
+
+func listeningSocketInodes(ctx context.Context, port int) map[string]bool {
+       wantPort := fmt.Sprintf("%04X", port)
+       inodes := map[string]bool{}
+       for _, path := range []string{"/proc/net/tcp", "/proc/net/tcp6"} {
+               if ctx.Err() != nil {
+                       return inodes
+               }
+               file, err := os.Open(path)
+               if err != nil {
+                       continue
+               }
+               scanner := bufio.NewScanner(file)
+               scanner.Scan() // header
+               for scanner.Scan() {
+                       if ctx.Err() != nil {
+                               break
+                       }
+                       fields := strings.Fields(scanner.Text())
+                       if len(fields) < 10 || !strings.EqualFold(fields[3], 
tcpListen) {
+                               continue
+                       }
+                       local := strings.Split(fields[1], ":")
+                       if len(local) != 2 || !strings.EqualFold(local[1], 
wantPort) {
+                               continue
+                       }
+                       inodes[fields[9]] = true
+               }
+               file.Close()
+       }
+       return inodes
+}
+
+func pidsHoldingInodes(ctx context.Context, inodes map[string]bool) []int {
+       entries, err := os.ReadDir("/proc")
+       if err != nil {
+               return nil
+       }
+
+       var pids []int
+       for _, entry := range entries {
+               if ctx.Err() != nil {
+                       return pids
+               }
+               pid, err := strconv.Atoi(entry.Name())
+               if err != nil {
+                       continue
+               }
+               fdDir := filepath.Join("/proc", entry.Name(), "fd")
+               descriptors, err := os.ReadDir(fdDir)
+               if err != nil {
+                       continue
+               }
+               for _, descriptor := range descriptors {
+                       link, err := os.Readlink(filepath.Join(fdDir, 
descriptor.Name()))
+                       if err != nil {
+                               continue
+                       }
+                       inode, ok := strings.CutPrefix(link, "socket:[")
+                       if !ok || !inodes[strings.TrimSuffix(inode, "]")] {
+                               continue
+                       }
+                       pids = append(pids, pid)
+                       break
+               }
+       }
+       return pids
+}
diff --git a/localserver/listener_windows.go b/localserver/listener_windows.go
new file mode 100644
index 0000000..3dbc3af
--- /dev/null
+++ b/localserver/listener_windows.go
@@ -0,0 +1,178 @@
+// Copyright 2025 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//go:build windows
+
+package localserver
+
+import (
+       "context"
+       "encoding/binary"
+       "syscall"
+       "unsafe"
+
+       "golang.org/x/sys/windows"
+)
+
+var (
+       iphlpapi                = windows.NewLazySystemDLL("iphlpapi.dll")
+       procGetExtendedTcpTable = iphlpapi.NewProc("GetExtendedTcpTable")
+)
+
+const (
+       // TCP_TABLE_OWNER_PID_LISTENER: listening sockets with their owning 
PID.
+       tcpTableOwnerPidListener = 3
+       // Both tables are a DWORD entry count followed by fixed-size rows.
+       tcpTableHeaderSize = 4
+       tcpRowSize         = 24
+       tcpRowPortIndex    = 8
+       tcpRowPidIndex     = 20
+       tcp6RowSize        = 56
+       tcp6RowPortIndex   = 20
+       tcp6RowPidIndex    = 52
+       tcpTableAttempts   = 4
+       initialTcpTable    = 32 * 1024
+       maxProcessPathLen  = windows.MAX_LONG_PATH
+)
+
+// Listeners resolves listening processes through Windows TCP tables.
+func Listeners(ctx context.Context, port int) []Process {
+       var result []Process
+       seen := map[uint32]bool{}
+       for _, pid := range listeningPids(ctx, port) {
+               if ctx.Err() != nil {
+                       return result
+               }
+               if pid == 0 || seen[pid] {
+                       continue
+               }
+               seen[pid] = true
+               if process, ok := describeProcess(ctx, pid); ok {
+                       result = append(result, process)
+               }
+       }
+       return result
+}
+
+func listeningPids(ctx context.Context, port int) []uint32 {
+       var pids []uint32
+       for _, table := range []struct {
+               family    uint32
+               rowSize   int
+               portIndex int
+               pidIndex  int
+       }{
+               {windows.AF_INET, tcpRowSize, tcpRowPortIndex, tcpRowPidIndex},
+               {windows.AF_INET6, tcp6RowSize, tcp6RowPortIndex, 
tcp6RowPidIndex},
+       } {
+               if ctx.Err() != nil {
+                       return pids
+               }
+               buffer, err := extendedTcpTable(ctx, table.family)
+               if err != nil {
+                       continue
+               }
+               pids = append(pids, tablePids(buffer, port, table.rowSize, 
table.portIndex, table.pidIndex)...)
+       }
+       return pids
+}
+
+// tablePids returns PIDs for rows matching port.
+func tablePids(table []byte, port, rowSize, portIndex, pidIndex int) []uint32 {
+       if len(table) < tcpTableHeaderSize {
+               return nil
+       }
+       entries := int(binary.LittleEndian.Uint32(table[:tcpTableHeaderSize]))
+
+       var pids []uint32
+       for i := 0; i < entries; i++ {
+               start := tcpTableHeaderSize + i*rowSize
+               if start+rowSize > len(table) {
+                       break
+               }
+               row := table[start : start+rowSize]
+               if int(binary.BigEndian.Uint16(row[portIndex:portIndex+2])) != 
port {
+                       continue
+               }
+               pids = append(pids, 
binary.LittleEndian.Uint32(row[pidIndex:pidIndex+4]))
+       }
+       return pids
+}
+
+func extendedTcpTable(ctx context.Context, family uint32) ([]byte, error) {
+       size := uint32(initialTcpTable)
+       for attempt := 0; attempt < tcpTableAttempts; attempt++ {
+               if err := ctx.Err(); err != nil {
+                       return nil, err
+               }
+               buffer := make([]byte, size)
+               ret, _, _ := procGetExtendedTcpTable.Call(
+                       uintptr(unsafe.Pointer(&buffer[0])), 
uintptr(unsafe.Pointer(&size)),
+                       0, uintptr(family), tcpTableOwnerPidListener, 0)
+               switch syscall.Errno(ret) {
+               case 0:
+                       return buffer, nil
+               case windows.ERROR_INSUFFICIENT_BUFFER:
+                       // size now holds the length the table needs; take 
another turn.
+                       continue
+               default:
+                       return nil, syscall.Errno(ret)
+               }
+       }
+       return nil, windows.ERROR_INSUFFICIENT_BUFFER
+}
+
+func describeProcess(ctx context.Context, pid uint32) (Process, bool) {
+       if ctx.Err() != nil {
+               return Process{}, false
+       }
+       handle, err := 
windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid)
+       if err != nil {
+               return Process{}, false
+       }
+       defer windows.CloseHandle(handle)
+
+       buffer := make([]uint16, maxProcessPathLen)
+       size := uint32(len(buffer))
+       if err := windows.QueryFullProcessImageName(handle, 0, &buffer[0], 
&size); err != nil {
+               return Process{}, false
+       }
+       return Process{
+               Pid:   int(pid),
+               Path:  windows.UTF16ToString(buffer[:size]),
+               Owner: processOwner(handle),
+       }, true
+}
+
+// processOwner returns the process account as DOMAIN\user when available.
+func processOwner(handle windows.Handle) string {
+       var token windows.Token
+       if err := windows.OpenProcessToken(handle, windows.TOKEN_QUERY, 
&token); err != nil {
+               return ""
+       }
+       defer token.Close()
+
+       tokenUser, err := token.GetTokenUser()
+       if err != nil {
+               return ""
+       }
+       account, domain, _, err := tokenUser.User.Sid.LookupAccount("")
+       if err != nil {
+               return ""
+       }
+       if domain == "" {
+               return account
+       }
+       return domain + `\` + account
+}
diff --git a/localserver/localserver.go b/localserver/localserver.go
new file mode 100644
index 0000000..0d4139c
--- /dev/null
+++ b/localserver/localserver.go
@@ -0,0 +1,146 @@
+// Copyright 2025 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package localserver identifies programs on configured loopback ports.
+package localserver
+
+import (
+       "context"
+       "encoding/json"
+       "io"
+       "net"
+       "net/http"
+       "strconv"
+       "strings"
+       "time"
+)
+
+const (
+       dialTimeout     = 300 * time.Millisecond
+       requestTimeout  = 2 * time.Second
+       maxResponseBody = 64 * 1024
+)
+
+// probeHosts restricts probes to the local machine.
+var probeHosts = []string{"127.0.0.1", "::1"}
+
+// Server describes how to confirm and query a local HTTP service.
+type Server struct {
+       Ports        []int    `json:"ports,omitempty"`
+       ProbePath    string   `json:"probePath,omitempty"`
+       ProbeMarkers []string `json:"probeMarkers,omitempty"`
+
+       VersionPath   string   `json:"versionPath,omitempty"`
+       VersionFields []string `json:"versionFields,omitempty"`
+}
+
+// Process is a process holding a listening TCP port.
+type Process struct {
+       Pid   int
+       Path  string
+       Owner string
+}
+
+// Confirm verifies the configured service on a loopback port.
+func (s *Server) Confirm(ctx context.Context, port int) (base string, ok bool) 
{
+       if s == nil || s.ProbePath == "" || len(s.ProbeMarkers) == 0 {
+               return "", false
+       }
+       for _, host := range probeHosts {
+               if ctx.Err() != nil {
+                       return "", false
+               }
+               address := net.JoinHostPort(host, strconv.Itoa(port))
+               dialer := net.Dialer{Timeout: dialTimeout}
+               conn, err := dialer.DialContext(ctx, "tcp", address)
+               if err != nil {
+                       continue
+               }
+               conn.Close()
+               base = "http://"; + address
+               if s.probe(ctx, base) {
+                       return base, true
+               }
+       }
+       return "", false
+}
+
+func (s *Server) probe(ctx context.Context, base string) bool {
+       answer, ok := get(ctx, base+s.ProbePath)
+       if !ok {
+               return false
+       }
+
+       haystack := strings.ToLower(answer.header + string(answer.body))
+       for _, marker := range s.ProbeMarkers {
+               if marker != "" && strings.Contains(haystack, 
strings.ToLower(marker)) {
+                       return true
+               }
+       }
+       return false
+}
+
+// Version reads the configured JSON version field from a confirmed service.
+func (s *Server) Version(ctx context.Context, base string) string {
+       if s == nil || s.VersionPath == "" || len(s.VersionFields) == 0 {
+               return ""
+       }
+       answer, ok := get(ctx, base+s.VersionPath)
+       if !ok {
+               return ""
+       }
+
+       var payload any
+       if json.Unmarshal(answer.body, &payload) != nil {
+               return ""
+       }
+       for _, field := range s.VersionFields {
+               object, ok := payload.(map[string]any)
+               if !ok {
+                       return ""
+               }
+               payload = object[field]
+       }
+       version, _ := payload.(string)
+       return version
+}
+
+type answer struct {
+       header string
+       body   []byte
+}
+
+func get(ctx context.Context, url string) (answer, bool) {
+       request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, 
nil)
+       if err != nil {
+               return answer{}, false
+       }
+       client := &http.Client{
+               Timeout:       requestTimeout,
+               CheckRedirect: func(*http.Request, []*http.Request) error { 
return http.ErrUseLastResponse },
+       }
+       response, err := client.Do(request)
+       if err != nil {
+               return answer{}, false
+       }
+       defer response.Body.Close()
+
+       var header strings.Builder
+       _ = response.Header.Write(&header)
+       body, err := io.ReadAll(io.LimitReader(response.Body, maxResponseBody))
+       if err != nil {
+               return answer{}, false
+       }
+       return answer{header: header.String(), body: body}, true
+}
diff --git a/routers/router.go b/routers/router.go
index d42c2c3..f110960 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -60,6 +60,7 @@ func initAPI() {
        beego.Router("/api/update-cert-domain-expire", 
&controllers.ApiController{}, "POST:UpdateCertDomainExpire")
 
        beego.Router("/api/get-applications", &controllers.ApiController{}, 
"GET:GetApplications")
+       beego.Router("/api/get-agents", &controllers.ApiController{}, 
"GET:GetAgents")
 
        beego.Router("/api/get-records", &controllers.ApiController{}, 
"GET:GetRecords")
        beego.Router("/api/get-record", &controllers.ApiController{}, 
"GET:GetRecord")
diff --git a/web/src/AgentsPage.js b/web/src/AgentsPage.js
new file mode 100644
index 0000000..8f2db39
--- /dev/null
+++ b/web/src/AgentsPage.js
@@ -0,0 +1,130 @@
+// Copyright 2025 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import React, {useCallback, useEffect, useState} from "react";
+import {Alert, Button, Result, Space, Table, Tag, Typography} from "antd";
+import {ReloadOutlined, RobotOutlined} from "@ant-design/icons";
+import i18next from "i18next";
+import * as AgentBackend from "./backend/AgentBackend";
+import * as Setting from "./Setting";
+import AgentIcon from "./components/AgentIcon";
+
+const {Text, Title} = Typography;
+
+const rowKey = record => `${record.owner}:${record.path}`;
+
+export default function AgentsPage({account}) {
+  const [agents, setAgents] = useState([]);
+  const [loading, setLoading] = useState(false);
+  const [error, setError] = useState("");
+  const isAdmin = Setting.isAdminUser(account);
+
+  const scan = useCallback((forceRefresh = false) => {
+    if (!isAdmin) {
+      return;
+    }
+
+    setLoading(true);
+    setError("");
+    AgentBackend.getAgents(forceRefresh).then(res => {
+      if (res.status === "ok") {
+        setAgents(res.data || []);
+      } else {
+        setError(res.msg || i18next.t("agent:Failed to scan agents"));
+      }
+    }).catch(err => {
+      setError(err.message || String(err));
+    }).then(() => {
+      setLoading(false);
+    });
+  }, [isAdmin]);
+
+  useEffect(() => {
+    scan();
+  }, [scan]);
+
+  if (!isAdmin) {
+    return (
+      <Result
+        status="403"
+        title="403 Unauthorized"
+        subTitle={i18next.t("general:Sorry, you do not have permission to 
access this page or logged in status invalid.")}
+        extra={<a href="/"><Button type="primary">{i18next.t("general:Back 
Home")}</Button></a>}
+      />
+    );
+  }
+
+  const columns = [
+    {
+      title: i18next.t("agent:Agent"),
+      dataIndex: "name",
+      key: "name",
+      render: (value, record) => (
+        <Space size={8}>
+          <AgentIcon
+            agent={record.agentId || value}
+            fallback={<RobotOutlined style={{fontSize: 18, color: "#8c8c8c"}} 
/>}
+          />
+          {value}
+        </Space>
+      ),
+    },
+    {
+      title: i18next.t("agent:Version"),
+      dataIndex: "version",
+      key: "version",
+      render: value => value || i18next.t("agent:Unknown"),
+    },
+    {
+      title: i18next.t("agent:Install Method"),
+      dataIndex: "installMethod",
+      key: "installMethod",
+      render: value => <Tag>{value || "-"}</Tag>,
+    },
+    {
+      title: i18next.t("general:Owner"),
+      dataIndex: "owner",
+      key: "owner",
+    },
+    {
+      title: i18next.t("general:Path"),
+      dataIndex: "path",
+      key: "path",
+      render: value => <Text code>{value}</Text>,
+    },
+  ];
+
+  return (
+    <div style={{padding: "24px"}}>
+      <Space style={{display: "flex", justifyContent: "space-between", 
marginBottom: 16}}>
+        <Title level={3} style={{margin: 
0}}>{i18next.t("agent:Agents")}</Title>
+        <Button icon={<ReloadOutlined />} loading={loading} onClick={() => 
scan(true)}>
+          {i18next.t("agent:Scan")}
+        </Button>
+      </Space>
+      {error && <Alert type="error" message={error} style={{marginBottom: 16}} 
/>}
+      <Table
+        rowKey={rowKey}
+        dataSource={agents}
+        columns={columns}
+        loading={loading}
+        pagination={false}
+        locale={{emptyText: i18next.t("agent:No supported agents found")}}
+        size="small"
+        bordered
+        scroll={{x: true}}
+      />
+    </div>
+  );
+}
diff --git a/web/src/App.js b/web/src/App.js
index 3055064..1fda6c1 100644
--- a/web/src/App.js
+++ b/web/src/App.js
@@ -15,7 +15,7 @@
 import React, {Component} from "react";
 import {Link, Redirect, Route, Switch, withRouter} from "react-router-dom";
 import {Avatar, BackTop, Drawer, Dropdown, Layout, Menu, Tooltip} from "antd";
-import {DeploymentUnitOutlined, DownOutlined, GithubOutlined, LogoutOutlined, 
SettingOutlined, ShareAltOutlined} from "@ant-design/icons";
+import {DeploymentUnitOutlined, DownOutlined, GithubOutlined, LogoutOutlined, 
RobotOutlined, SettingOutlined, ShareAltOutlined} from "@ant-design/icons";
 import "./App.less";
 import * as Setting from "./Setting";
 import * as AccountBackend from "./backend/AccountBackend";
@@ -37,6 +37,7 @@ import RecordListPage from "./RecordListPage";
 import RecordEditPage from "./RecordEditPage";
 import i18next from "i18next";
 import DashboardPage from "./DashboardPage";
+import AgentsPage from "./AgentsPage";
 import LanguageSelect from "./LanguageSelect";
 import {withTranslation} from "react-i18next";
 // import SelectLanguageBox from "./SelectLanguageBox";
@@ -81,6 +82,8 @@ class App extends Component {
       this.setState({selectedMenuKey: "/"});
     } else if (uri.includes("/dashboard")) {
       this.setState({selectedMenuKey: "/dashboard"});
+    } else if (uri.includes("/agents")) {
+      this.setState({selectedMenuKey: "/agents"});
     } else if (uri.includes("/nodes")) {
       this.setState({selectedMenuKey: "/nodes"});
     } else if (uri.includes("/sites")) {
@@ -286,6 +289,15 @@ class App extends Component {
         </Link>
       </Menu.Item>
     );
+    if (Setting.isAdminUser(this.state.account)) {
+      res.push(
+        <Menu.Item key="/agents" icon={<RobotOutlined />}>
+          <Link to="/agents">
+            {i18next.t("agent:Agents")}
+          </Link>
+        </Menu.Item>
+      );
+    }
     res.push(
       <Menu.Item key="/nodes">
         <Link to="/nodes">
@@ -384,6 +396,7 @@ class App extends Component {
           <Route exact path="/home" render={(props) => <HomePage 
account={this.state.account} {...props} />} />
           <Route exact path="/" render={(props) => <Redirect to="/sites" />} />
           <Route exact path="/signin" render={(props) => 
this.renderHomeIfSignedIn(<SigninPage {...props} />)} />
+          <Route exact path="/agents" render={(props) => 
this.renderSigninIfNotSignedIn(<AgentsPage account={this.state.account} 
{...props} />)} />
           <Route exact path="/nodes" render={(props) => 
this.renderSigninIfNotSignedIn(<NodeListPage account={this.state.account} 
{...props} />)} />
           <Route exact path="/nodes/:owner/:nodeName" render={(props) => 
this.renderSigninIfNotSignedIn(<NodeEditPage account={this.state.account} 
{...props} />)} />
           <Route exact path="/sites" render={(props) => 
this.renderSigninIfNotSignedIn(<SiteListPage account={this.state.account} 
{...props} />)} />
diff --git a/web/src/backend/AgentBackend.js b/web/src/backend/AgentBackend.js
new file mode 100644
index 0000000..7bc3a7b
--- /dev/null
+++ b/web/src/backend/AgentBackend.js
@@ -0,0 +1,23 @@
+// Copyright 2025 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import * as Setting from "../Setting";
+
+export function getAgents(forceRefresh = false) {
+  const query = forceRefresh ? "?refresh=true" : "";
+  return fetch(`${Setting.ServerUrl}/api/get-agents${query}`, {
+    method: "GET",
+    credentials: "include",
+  }).then(res => res.json());
+}
diff --git a/web/src/components/AgentIcon.js b/web/src/components/AgentIcon.js
new file mode 100644
index 0000000..81aa48f
--- /dev/null
+++ b/web/src/components/AgentIcon.js
@@ -0,0 +1,56 @@
+// Copyright 2025 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import React, {useState} from "react";
+
+const agentSites = {
+  "Claude Code": "claude.com",
+  "Claude Desktop": "claude.ai",
+  "OpenClaw": "openclaw.ai",
+  "Codex": "openai.com",
+  "ChatGPT Desktop (Codex)": "openai.com",
+  "Codex CLI": "openai.com",
+  "Hermes Agent": "hermes-agent.nousresearch.com",
+  "Cursor": "cursor.com",
+  "Cursor Agent": "cursor.com",
+  "Windsurf": "windsurf.com",
+  "OpenAgent": "openagentai.org",
+};
+
+const agentKey = agent => String(agent || 
"").toLowerCase().replace(/[^a-z0-9]/g, "");
+
+const agentSitesByKey = Object.keys(agentSites).reduce((sites, name) => {
+  sites[agentKey(name)] = agentSites[name];
+  return sites;
+}, {});
+
+export default function AgentIcon({agent, fallback = null, size = 20}) {
+  const [broken, setBroken] = useState(false);
+  const site = agentSitesByKey[agentKey(agent)];
+
+  if (!site || broken) {
+    return fallback;
+  }
+
+  return (
+    <img
+      src={`https://www.google.com/s2/favicons?domain=${site}&sz=64`}
+      alt={agent}
+      width={size}
+      height={size}
+      onError={() => setBroken(true)}
+      style={{display: "block", borderRadius: 6}}
+    />
+  );
+}
diff --git a/web/src/locales/de/data.json b/web/src/locales/de/data.json
index f1dd820..15849e6 100644
--- a/web/src/locales/de/data.json
+++ b/web/src/locales/de/data.json
@@ -5,6 +5,16 @@
     "Sign Out": "Sign Out",
     "Sign Up": "Sign Up"
   },
+  "agent": {
+    "Agent": "Agent",
+    "Agents": "Agents",
+    "Failed to scan agents": "Failed to scan agents",
+    "Install Method": "Install Method",
+    "No supported agents found": "No supported agents found",
+    "Scan": "Scan",
+    "Unknown": "Unknown",
+    "Version": "Version"
+  },
   "cert": {
     "Access key": "Access key",
     "Access secret": "Access secret",
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index 739b57a..13143b5 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -5,6 +5,16 @@
     "Sign Out": "Sign Out",
     "Sign Up": "Sign Up"
   },
+  "agent": {
+    "Agent": "Agent",
+    "Agents": "Agents",
+    "Failed to scan agents": "Failed to scan agents",
+    "Install Method": "Install Method",
+    "No supported agents found": "No supported agents found",
+    "Scan": "Scan",
+    "Unknown": "Unknown",
+    "Version": "Version"
+  },
   "cert": {
     "Access key": "Access key",
     "Access secret": "Access secret",
diff --git a/web/src/locales/es/data.json b/web/src/locales/es/data.json
index fb61e9f..75d43b7 100644
--- a/web/src/locales/es/data.json
+++ b/web/src/locales/es/data.json
@@ -5,6 +5,16 @@
     "Sign Out": "Sign Out",
     "Sign Up": "Sign Up"
   },
+  "agent": {
+    "Agent": "Agent",
+    "Agents": "Agents",
+    "Failed to scan agents": "Failed to scan agents",
+    "Install Method": "Install Method",
+    "No supported agents found": "No supported agents found",
+    "Scan": "Scan",
+    "Unknown": "Unknown",
+    "Version": "Version"
+  },
   "cert": {
     "Access key": "Access key",
     "Access secret": "Access secret",
diff --git a/web/src/locales/fr/data.json b/web/src/locales/fr/data.json
index 3a7d12a..542e10e 100644
--- a/web/src/locales/fr/data.json
+++ b/web/src/locales/fr/data.json
@@ -5,6 +5,16 @@
     "Sign Out": "Sign Out",
     "Sign Up": "Sign Up"
   },
+  "agent": {
+    "Agent": "Agent",
+    "Agents": "Agents",
+    "Failed to scan agents": "Failed to scan agents",
+    "Install Method": "Install Method",
+    "No supported agents found": "No supported agents found",
+    "Scan": "Scan",
+    "Unknown": "Unknown",
+    "Version": "Version"
+  },
   "cert": {
     "Access key": "Access key",
     "Access secret": "Access secret",
diff --git a/web/src/locales/id/data.json b/web/src/locales/id/data.json
index 84bfbca..b2e4c8c 100644
--- a/web/src/locales/id/data.json
+++ b/web/src/locales/id/data.json
@@ -5,6 +5,16 @@
     "Sign Out": "Sign Out",
     "Sign Up": "Sign Up"
   },
+  "agent": {
+    "Agent": "Agent",
+    "Agents": "Agents",
+    "Failed to scan agents": "Failed to scan agents",
+    "Install Method": "Install Method",
+    "No supported agents found": "No supported agents found",
+    "Scan": "Scan",
+    "Unknown": "Unknown",
+    "Version": "Version"
+  },
   "cert": {
     "Access key": "Access key",
     "Access secret": "Access secret",
diff --git a/web/src/locales/ja/data.json b/web/src/locales/ja/data.json
index d4c22df..2db0e02 100644
--- a/web/src/locales/ja/data.json
+++ b/web/src/locales/ja/data.json
@@ -5,6 +5,16 @@
     "Sign Out": "Sign Out",
     "Sign Up": "Sign Up"
   },
+  "agent": {
+    "Agent": "Agent",
+    "Agents": "Agents",
+    "Failed to scan agents": "Failed to scan agents",
+    "Install Method": "Install Method",
+    "No supported agents found": "No supported agents found",
+    "Scan": "Scan",
+    "Unknown": "Unknown",
+    "Version": "Version"
+  },
   "cert": {
     "Access key": "Access key",
     "Access secret": "Access secret",
diff --git a/web/src/locales/ko/data.json b/web/src/locales/ko/data.json
index eb13620..64d1fe6 100644
--- a/web/src/locales/ko/data.json
+++ b/web/src/locales/ko/data.json
@@ -5,6 +5,16 @@
     "Sign Out": "Sign Out",
     "Sign Up": "Sign Up"
   },
+  "agent": {
+    "Agent": "Agent",
+    "Agents": "Agents",
+    "Failed to scan agents": "Failed to scan agents",
+    "Install Method": "Install Method",
+    "No supported agents found": "No supported agents found",
+    "Scan": "Scan",
+    "Unknown": "Unknown",
+    "Version": "Version"
+  },
   "cert": {
     "Access key": "Access key",
     "Access secret": "Access secret",
diff --git a/web/src/locales/ru/data.json b/web/src/locales/ru/data.json
index d9e093d..bd26863 100644
--- a/web/src/locales/ru/data.json
+++ b/web/src/locales/ru/data.json
@@ -5,6 +5,16 @@
     "Sign Out": "Sign Out",
     "Sign Up": "Sign Up"
   },
+  "agent": {
+    "Agent": "Agent",
+    "Agents": "Agents",
+    "Failed to scan agents": "Failed to scan agents",
+    "Install Method": "Install Method",
+    "No supported agents found": "No supported agents found",
+    "Scan": "Scan",
+    "Unknown": "Unknown",
+    "Version": "Version"
+  },
   "cert": {
     "Access key": "Access key",
     "Access secret": "Access secret",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 35c83f0..2dc6603 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -5,6 +5,16 @@
     "Sign Out": "退出",
     "Sign Up": "注册"
   },
+  "agent": {
+    "Agent": "Agent",
+    "Agents": "Agent 列表",
+    "Failed to scan agents": "探测 Agent 失败",
+    "Install Method": "安装方式",
+    "No supported agents found": "未发现支持的 Agent",
+    "Scan": "探测",
+    "Unknown": "未知",
+    "Version": "版本"
+  },
   "cert": {
     "Access key": "访问密钥",
     "Access secret": "访问密钥",


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to